/+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: Gerald Kaszuba
  • Date: 2010-12-24 05:18:55 UTC
  • Revision ID: git-v1:1a633e0584a557e812fae20deceaef514a515f62
gitignore compiled py files and setup.py build dir

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-2009 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
from __future__ import division
 
23
 
 
24
import os
 
25
import urllib
 
26
import urllib2
 
27
import math
 
28
import random
 
29
import re
 
30
import warnings
 
31
import copy
 
32
 
 
33
# Helper variables and functions
 
34
# -----------------------------------------------------------------------------
 
35
 
 
36
__version__ = '0.3.0'
 
37
__author__ = 'Gerald Kaszuba'
 
38
 
 
39
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
 
40
 
 
41
def _check_colour(colour):
 
42
    if not reo_colour.match(colour):
 
43
        raise InvalidParametersException('Colours need to be in ' \
 
44
            'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
 
45
            colour)
 
46
 
 
47
 
 
48
def _reset_warnings():
 
49
    """Helper function to reset all warnings. Used by the unit tests."""
 
50
    globals()['__warningregistry__'] = None
 
51
 
 
52
 
 
53
# Exception Classes
 
54
# -----------------------------------------------------------------------------
 
55
 
 
56
 
 
57
class PyGoogleChartException(Exception):
 
58
    pass
 
59
 
 
60
 
 
61
class DataOutOfRangeException(PyGoogleChartException):
 
62
    pass
 
63
 
 
64
 
 
65
class UnknownDataTypeException(PyGoogleChartException):
 
66
    pass
 
67
 
 
68
 
 
69
class NoDataGivenException(PyGoogleChartException):
 
70
    pass
 
71
 
 
72
 
 
73
class InvalidParametersException(PyGoogleChartException):
 
74
    pass
 
75
 
 
76
 
 
77
class BadContentTypeException(PyGoogleChartException):
 
78
    pass
 
79
 
 
80
 
 
81
class AbstractClassException(PyGoogleChartException):
 
82
    pass
 
83
 
 
84
 
 
85
class UnknownChartType(PyGoogleChartException):
 
86
    pass
 
87
 
 
88
class UnknownCountryCodeException(PyGoogleChartException):
 
89
    pass
 
90
 
 
91
# Data Classes
 
92
# -----------------------------------------------------------------------------
 
93
 
 
94
 
 
95
class Data(object):
 
96
 
 
97
    def __init__(self, data):
 
98
        if type(self) == Data:
 
99
            raise AbstractClassException('This is an abstract class')
 
100
        self.data = data
 
101
 
 
102
    @classmethod
 
103
    def float_scale_value(cls, value, range):
 
104
        lower, upper = range
 
105
        assert(upper > lower)
 
106
        scaled = (value - lower) * (cls.max_value / (upper - lower))
 
107
        return scaled
 
108
 
 
109
    @classmethod
 
110
    def clip_value(cls, value):
 
111
        return max(0, min(value, cls.max_value))
 
112
 
 
113
    @classmethod
 
114
    def int_scale_value(cls, value, range):
 
115
        return int(round(cls.float_scale_value(value, range)))
 
116
 
 
117
    @classmethod
 
118
    def scale_value(cls, value, range):
 
119
        scaled = cls.int_scale_value(value, range)
 
120
        clipped = cls.clip_value(scaled)
 
121
        Data.check_clip(scaled, clipped)
 
122
        return clipped
 
123
 
 
124
    @staticmethod
 
125
    def check_clip(scaled, clipped):
 
126
        if clipped != scaled:
 
127
            warnings.warn('One or more of of your data points has been '
 
128
                'clipped because it is out of range.')
 
129
 
 
130
 
 
131
class SimpleData(Data):
 
132
 
 
133
    max_value = 61
 
134
    enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
 
135
 
 
136
    def __repr__(self):
 
137
        encoded_data = []
 
138
        for data in self.data:
 
139
            sub_data = []
 
140
            for value in data:
 
141
                if value is None:
 
142
                    sub_data.append('_')
 
143
                elif value >= 0 and value <= self.max_value:
 
144
                    sub_data.append(SimpleData.enc_map[value])
 
145
                else:
 
146
                    raise DataOutOfRangeException('cannot encode value: %d'
 
147
                                                  % value)
 
148
            encoded_data.append(''.join(sub_data))
 
149
        return 'chd=s:' + ','.join(encoded_data)
 
150
 
 
151
 
 
152
class TextData(Data):
 
153
 
 
154
    max_value = 100
 
155
 
 
156
    def __repr__(self):
 
157
        encoded_data = []
 
158
        for data in self.data:
 
159
            sub_data = []
 
160
            for value in data:
 
161
                if value is None:
 
162
                    sub_data.append(-1)
 
163
                elif value >= 0 and value <= self.max_value:
 
164
                    sub_data.append("%.1f" % float(value))
 
165
                else:
 
166
                    raise DataOutOfRangeException()
 
167
            encoded_data.append(','.join(sub_data))
 
168
        return 'chd=t:' + '%7c'.join(encoded_data)
 
169
 
 
170
    @classmethod
 
171
    def scale_value(cls, value, range):
 
172
        # use float values instead of integers because we don't need an encode
 
173
        # map index
 
174
        scaled = cls.float_scale_value(value, range)
 
175
        clipped = cls.clip_value(scaled)
 
176
        Data.check_clip(scaled, clipped)
 
177
        return clipped
 
178
 
 
179
 
 
180
class ExtendedData(Data):
 
181
 
 
182
    max_value = 4095
 
183
    enc_map = \
 
184
        'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
 
185
 
 
186
    def __repr__(self):
 
187
        encoded_data = []
 
188
        enc_size = len(ExtendedData.enc_map)
 
189
        for data in self.data:
 
190
            sub_data = []
 
191
            for value in data:
 
192
                if value is None:
 
193
                    sub_data.append('__')
 
194
                elif value >= 0 and value <= self.max_value:
 
195
                    first, second = divmod(int(value), enc_size)
 
196
                    sub_data.append('%s%s' % (
 
197
                        ExtendedData.enc_map[first],
 
198
                        ExtendedData.enc_map[second]))
 
199
                else:
 
200
                    raise DataOutOfRangeException( \
 
201
                        'Item #%i "%s" is out of range' % (data.index(value), \
 
202
                        value))
 
203
            encoded_data.append(''.join(sub_data))
 
204
        return 'chd=e:' + ','.join(encoded_data)
 
205
 
 
206
 
 
207
# Axis Classes
 
208
# -----------------------------------------------------------------------------
 
209
 
 
210
 
 
211
class Axis(object):
 
212
 
 
213
    BOTTOM = 'x'
 
214
    TOP = 't'
 
215
    LEFT = 'y'
 
216
    RIGHT = 'r'
 
217
    TYPES = (BOTTOM, TOP, LEFT, RIGHT)
 
218
 
 
219
    def __init__(self, axis_index, axis_type, **kw):
 
220
        assert(axis_type in Axis.TYPES)
 
221
        self.has_style = False
 
222
        self.axis_index = axis_index
 
223
        self.axis_type = axis_type
 
224
        self.positions = None
 
225
 
 
226
    def set_index(self, axis_index):
 
227
        self.axis_index = axis_index
 
228
 
 
229
    def set_positions(self, positions):
 
230
        self.positions = positions
 
231
 
 
232
    def set_style(self, colour, font_size=None, alignment=None):
 
233
        _check_colour(colour)
 
234
        self.colour = colour
 
235
        self.font_size = font_size
 
236
        self.alignment = alignment
 
237
        self.has_style = True
 
238
 
 
239
    def style_to_url(self):
 
240
        bits = []
 
241
        bits.append(str(self.axis_index))
 
242
        bits.append(self.colour)
 
243
        if self.font_size is not None:
 
244
            bits.append(str(self.font_size))
 
245
            if self.alignment is not None:
 
246
                bits.append(str(self.alignment))
 
247
        return ','.join(bits)
 
248
 
 
249
    def positions_to_url(self):
 
250
        bits = []
 
251
        bits.append(str(self.axis_index))
 
252
        bits += [str(a) for a in self.positions]
 
253
        return ','.join(bits)
 
254
 
 
255
 
 
256
class LabelAxis(Axis):
 
257
 
 
258
    def __init__(self, axis_index, axis_type, values, **kwargs):
 
259
        Axis.__init__(self, axis_index, axis_type, **kwargs)
 
260
        self.values = [str(a) for a in values]
 
261
 
 
262
    def __repr__(self):
 
263
        return '%i:%%7c%s' % (self.axis_index, '%7c'.join(self.values))
 
264
 
 
265
 
 
266
class RangeAxis(Axis):
 
267
 
 
268
    def __init__(self, axis_index, axis_type, low, high, **kwargs):
 
269
        Axis.__init__(self, axis_index, axis_type, **kwargs)
 
270
        self.low = low
 
271
        self.high = high
 
272
 
 
273
    def __repr__(self):
 
274
        return '%i,%s,%s' % (self.axis_index, self.low, self.high)
 
275
 
 
276
# Chart Classes
 
277
# -----------------------------------------------------------------------------
 
278
 
 
279
 
 
280
class Chart(object):
 
281
    """Abstract class for all chart types.
 
282
 
 
283
    width are height specify the dimensions of the image. title sets the title
 
284
    of the chart. legend requires a list that corresponds to datasets.
 
285
    """
 
286
 
 
287
    BASE_URL = 'http://chart.apis.google.com/chart?'
 
288
    BACKGROUND = 'bg'
 
289
    CHART = 'c'
 
290
    ALPHA = 'a'
 
291
    VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA)
 
292
    SOLID = 's'
 
293
    LINEAR_GRADIENT = 'lg'
 
294
    LINEAR_STRIPES = 'ls'
 
295
 
 
296
    def __init__(self, width, height, title=None, legend=None, colours=None,
 
297
            auto_scale=True, x_range=None, y_range=None,
 
298
            colours_within_series=None):
 
299
        if type(self) == Chart:
 
300
            raise AbstractClassException('This is an abstract class')
 
301
        assert(isinstance(width, int))
 
302
        assert(isinstance(height, int))
 
303
        self.width = width
 
304
        self.height = height
 
305
        self.data = []
 
306
        self.set_title(title)
 
307
        self.set_title_style(None, None)
 
308
        self.set_legend(legend)
 
309
        self.set_legend_position(None)
 
310
        self.set_colours(colours)
 
311
        self.set_colours_within_series(colours_within_series)
 
312
 
 
313
        # Data for scaling.
 
314
        self.auto_scale = auto_scale  # Whether to automatically scale data
 
315
        self.x_range = x_range  # (min, max) x-axis range for scaling
 
316
        self.y_range = y_range  # (min, max) y-axis range for scaling
 
317
        self.scaled_data_class = None
 
318
        self.scaled_x_range = None
 
319
        self.scaled_y_range = None
 
320
 
 
321
        self.fill_types = {
 
322
            Chart.BACKGROUND: None,
 
323
            Chart.CHART: None,
 
324
            Chart.ALPHA: None,
 
325
        }
 
326
        self.fill_area = {
 
327
            Chart.BACKGROUND: None,
 
328
            Chart.CHART: None,
 
329
            Chart.ALPHA: None,
 
330
        }
 
331
        self.axis = []
 
332
        self.markers = []
 
333
        self.line_styles = {}
 
334
        self.grid = None
 
335
        self.title_colour = None
 
336
        self.title_font_size = None
 
337
 
 
338
    # URL generation
 
339
    # -------------------------------------------------------------------------
 
340
 
 
341
    def get_url(self, data_class=None):
 
342
        url_bits = self.get_url_bits(data_class=data_class)
 
343
        return self.BASE_URL + '&'.join(url_bits)
 
344
 
 
345
    def get_url_bits(self, data_class=None):
 
346
        url_bits = []
 
347
        # required arguments
 
348
        url_bits.append(self.type_to_url())
 
349
        url_bits.append('chs=%ix%i' % (self.width, self.height))
 
350
        url_bits.append(self.data_to_url(data_class=data_class))
 
351
        # optional arguments
 
352
        if self.title:
 
353
            url_bits.append('chtt=%s' % self.title)
 
354
        if self.title_colour and self.title_font_size:
 
355
            url_bits.append('chts=%s,%s' % (self.title_colour, \
 
356
                self.title_font_size))
 
357
        if self.legend:
 
358
            url_bits.append('chdl=%s' % '%7c'.join(self.legend))
 
359
        if self.legend_position:
 
360
            url_bits.append('chdlp=%s' % (self.legend_position))
 
361
        if self.colours:
 
362
            url_bits.append('chco=%s' % ','.join(self.colours))            
 
363
        if self.colours_within_series:
 
364
            url_bits.append('chco=%s' % '%7c'.join(self.colours_within_series))
 
365
        ret = self.fill_to_url()
 
366
        if ret:
 
367
            url_bits.append(ret)
 
368
        ret = self.axis_to_url()
 
369
        if ret:
 
370
            url_bits.append(ret)                    
 
371
        if self.markers:
 
372
            url_bits.append(self.markers_to_url())        
 
373
        if self.line_styles:
 
374
            style = []
 
375
            for index in xrange(max(self.line_styles) + 1):
 
376
                if index in self.line_styles:
 
377
                    values = self.line_styles[index]
 
378
                else:
 
379
                    values = ('1', )
 
380
                style.append(','.join(values))
 
381
            url_bits.append('chls=%s' % '%7c'.join(style))
 
382
        if self.grid:
 
383
            url_bits.append('chg=%s' % self.grid)
 
384
        return url_bits
 
385
 
 
386
    # Downloading
 
387
    # -------------------------------------------------------------------------
 
388
 
 
389
    def download(self, file_name):
 
390
        opener = urllib2.urlopen(self.get_url())
 
391
 
 
392
        if opener.headers['content-type'] != 'image/png':
 
393
            raise BadContentTypeException('Server responded with a ' \
 
394
                'content-type of %s' % opener.headers['content-type'])
 
395
 
 
396
        open(file_name, 'wb').write(opener.read())
 
397
 
 
398
    # Simple settings
 
399
    # -------------------------------------------------------------------------
 
400
 
 
401
    def set_title(self, title):
 
402
        if title:
 
403
            self.title = urllib.quote(title)
 
404
        else:
 
405
            self.title = None
 
406
 
 
407
    def set_title_style(self, colour=None, font_size=None):
 
408
        if not colour is None:
 
409
            _check_colour(colour)
 
410
        if not colour and not font_size:
 
411
            return
 
412
        self.title_colour = colour or '333333'
 
413
        self.title_font_size = font_size or 13.5
 
414
 
 
415
    def set_legend(self, legend):
 
416
        """legend needs to be a list, tuple or None"""
 
417
        assert(isinstance(legend, list) or isinstance(legend, tuple) or
 
418
            legend is None)
 
419
        if legend:
 
420
            self.legend = [urllib.quote(a) for a in legend]
 
421
        else:
 
422
            self.legend = None
 
423
 
 
424
    def set_legend_position(self, legend_position):
 
425
        if legend_position:
 
426
            self.legend_position = urllib.quote(legend_position)
 
427
        else:    
 
428
            self.legend_position = None
 
429
 
 
430
    # Chart colours
 
431
    # -------------------------------------------------------------------------
 
432
 
 
433
    def set_colours(self, colours):
 
434
        # colours needs to be a list, tuple or None
 
435
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
436
            colours is None)
 
437
        # make sure the colours are in the right format
 
438
        if colours:
 
439
            for col in colours:
 
440
                _check_colour(col)
 
441
        self.colours = colours
 
442
 
 
443
    def set_colours_within_series(self, colours):
 
444
        # colours needs to be a list, tuple or None
 
445
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
446
            colours is None)
 
447
        # make sure the colours are in the right format
 
448
        if colours:
 
449
            for col in colours:
 
450
                _check_colour(col)
 
451
        self.colours_within_series = colours        
 
452
 
 
453
    # Background/Chart colours
 
454
    # -------------------------------------------------------------------------
 
455
 
 
456
    def fill_solid(self, area, colour):
 
457
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
458
        _check_colour(colour)
 
459
        self.fill_area[area] = colour
 
460
        self.fill_types[area] = Chart.SOLID
 
461
 
 
462
    def _check_fill_linear(self, angle, *args):
 
463
        assert(isinstance(args, list) or isinstance(args, tuple))
 
464
        assert(angle >= 0 and angle <= 90)
 
465
        assert(len(args) % 2 == 0)
 
466
        args = list(args)  # args is probably a tuple and we need to mutate
 
467
        for a in xrange(int(len(args) / 2)):
 
468
            col = args[a * 2]
 
469
            offset = args[a * 2 + 1]
 
470
            _check_colour(col)
 
471
            assert(offset >= 0 and offset <= 1)
 
472
            args[a * 2 + 1] = str(args[a * 2 + 1])
 
473
        return args
 
474
 
 
475
    def fill_linear_gradient(self, area, angle, *args):
 
476
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
477
        args = self._check_fill_linear(angle, *args)
 
478
        self.fill_types[area] = Chart.LINEAR_GRADIENT
 
479
        self.fill_area[area] = ','.join([str(angle)] + args)
 
480
 
 
481
    def fill_linear_stripes(self, area, angle, *args):
 
482
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
483
        args = self._check_fill_linear(angle, *args)
 
484
        self.fill_types[area] = Chart.LINEAR_STRIPES
 
485
        self.fill_area[area] = ','.join([str(angle)] + args)
 
486
 
 
487
    def fill_to_url(self):
 
488
        areas = []
 
489
        for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
 
490
            if self.fill_types[area]:
 
491
                areas.append('%s,%s,%s' % (area, self.fill_types[area], \
 
492
                    self.fill_area[area]))
 
493
        if areas:
 
494
            return 'chf=' + '%7c'.join(areas)
 
495
 
 
496
    # Data
 
497
    # -------------------------------------------------------------------------
 
498
 
 
499
    def data_class_detection(self, data):
 
500
        """Determines the appropriate data encoding type to give satisfactory
 
501
        resolution (http://code.google.com/apis/chart/#chart_data).
 
502
        """
 
503
        assert(isinstance(data, list) or isinstance(data, tuple))
 
504
        if not isinstance(self, (LineChart, BarChart, ScatterChart)):
 
505
            # From the link above:
 
506
            #   Simple encoding is suitable for all other types of chart
 
507
            #   regardless of size.
 
508
            return SimpleData
 
509
        elif self.height < 100:
 
510
            # The link above indicates that line and bar charts less
 
511
            # than 300px in size can be suitably represented with the
 
512
            # simple encoding. I've found that this isn't sufficient,
 
513
            # e.g. examples/line-xy-circle.png. Let's try 100px.
 
514
            return SimpleData
 
515
        else:
 
516
            return ExtendedData
 
517
 
 
518
    def _filter_none(self, data):
 
519
        return [r for r in data if r is not None]
 
520
 
 
521
    def data_x_range(self):
 
522
        """Return a 2-tuple giving the minimum and maximum x-axis
 
523
        data range.
 
524
        """
 
525
        try:
 
526
            lower = min([min(self._filter_none(s))
 
527
                         for type, s in self.annotated_data()
 
528
                         if type == 'x'])
 
529
            upper = max([max(self._filter_none(s))
 
530
                         for type, s in self.annotated_data()
 
531
                         if type == 'x'])
 
532
            return (lower, upper)
 
533
        except ValueError:
 
534
            return None     # no x-axis datasets
 
535
 
 
536
    def data_y_range(self):
 
537
        """Return a 2-tuple giving the minimum and maximum y-axis
 
538
        data range.
 
539
        """
 
540
        try:
 
541
            lower = min([min(self._filter_none(s))
 
542
                         for type, s in self.annotated_data()
 
543
                         if type == 'y'])
 
544
            upper = max([max(self._filter_none(s)) + 1
 
545
                         for type, s in self.annotated_data()
 
546
                         if type == 'y'])
 
547
            return (lower, upper)
 
548
        except ValueError:
 
549
            return None     # no y-axis datasets
 
550
 
 
551
    def scaled_data(self, data_class, x_range=None, y_range=None):
 
552
        """Scale `self.data` as appropriate for the given data encoding
 
553
        (data_class) and return it.
 
554
 
 
555
        An optional `y_range` -- a 2-tuple (lower, upper) -- can be
 
556
        given to specify the y-axis bounds. If not given, the range is
 
557
        inferred from the data: (0, <max-value>) presuming no negative
 
558
        values, or (<min-value>, <max-value>) if there are negative
 
559
        values.  `self.scaled_y_range` is set to the actual lower and
 
560
        upper scaling range.
 
561
 
 
562
        Ditto for `x_range`. Note that some chart types don't have x-axis
 
563
        data.
 
564
        """
 
565
        self.scaled_data_class = data_class
 
566
 
 
567
        # Determine the x-axis range for scaling.
 
568
        if x_range is None:
 
569
            x_range = self.data_x_range()
 
570
            if x_range and x_range[0] > 0:
 
571
                x_range = (x_range[0], x_range[1])
 
572
        self.scaled_x_range = x_range
 
573
 
 
574
        # Determine the y-axis range for scaling.
 
575
        if y_range is None:
 
576
            y_range = self.data_y_range()
 
577
            if y_range and y_range[0] > 0:
 
578
                y_range = (y_range[0], y_range[1])
 
579
        self.scaled_y_range = y_range
 
580
 
 
581
        scaled_data = []
 
582
        for type, dataset in self.annotated_data():
 
583
            if type == 'x':
 
584
                scale_range = x_range
 
585
            elif type == 'y':
 
586
                scale_range = y_range
 
587
            elif type == 'marker-size':
 
588
                scale_range = (0, max(dataset))
 
589
            scaled_dataset = []
 
590
            for v in dataset:
 
591
                if v is None:
 
592
                    scaled_dataset.append(None)
 
593
                else:
 
594
                    scaled_dataset.append(
 
595
                        data_class.scale_value(v, scale_range))
 
596
            scaled_data.append(scaled_dataset)
 
597
        return scaled_data
 
598
 
 
599
    def add_data(self, data):
 
600
        self.data.append(data)
 
601
        return len(self.data) - 1  # return the "index" of the data set
 
602
 
 
603
    def data_to_url(self, data_class=None):
 
604
        if not data_class:
 
605
            data_class = self.data_class_detection(self.data)
 
606
        if not issubclass(data_class, Data):
 
607
            raise UnknownDataTypeException()
 
608
        if self.auto_scale:
 
609
            data = self.scaled_data(data_class, self.x_range, self.y_range)
 
610
        else:
 
611
            data = self.data
 
612
        return repr(data_class(data))
 
613
 
 
614
    def annotated_data(self):
 
615
        for dataset in self.data:
 
616
            yield ('x', dataset)
 
617
 
 
618
    # Axis Labels
 
619
    # -------------------------------------------------------------------------
 
620
 
 
621
    def set_axis_labels(self, axis_type, values):
 
622
        assert(axis_type in Axis.TYPES)
 
623
        values = [urllib.quote(str(a)) for a in values]
 
624
        axis_index = len(self.axis)
 
625
        axis = LabelAxis(axis_index, axis_type, values)
 
626
        self.axis.append(axis)
 
627
        return axis_index
 
628
 
 
629
    def set_axis_range(self, axis_type, low, high):
 
630
        assert(axis_type in Axis.TYPES)
 
631
        axis_index = len(self.axis)
 
632
        axis = RangeAxis(axis_index, axis_type, low, high)
 
633
        self.axis.append(axis)
 
634
        return axis_index
 
635
 
 
636
    def set_axis_positions(self, axis_index, positions):
 
637
        try:
 
638
            self.axis[axis_index].set_positions(positions)
 
639
        except IndexError:
 
640
            raise InvalidParametersException('Axis index %i has not been ' \
 
641
                'created' % axis)
 
642
 
 
643
    def set_axis_style(self, axis_index, colour, font_size=None, \
 
644
            alignment=None):
 
645
        try:
 
646
            self.axis[axis_index].set_style(colour, font_size, alignment)
 
647
        except IndexError:
 
648
            raise InvalidParametersException('Axis index %i has not been ' \
 
649
                'created' % axis)
 
650
 
 
651
    def axis_to_url(self):
 
652
        available_axis = []
 
653
        label_axis = []
 
654
        range_axis = []
 
655
        positions = []
 
656
        styles = []
 
657
        index = -1
 
658
        for axis in self.axis:
 
659
            available_axis.append(axis.axis_type)
 
660
            if isinstance(axis, RangeAxis):
 
661
                range_axis.append(repr(axis))
 
662
            if isinstance(axis, LabelAxis):
 
663
                label_axis.append(repr(axis))
 
664
            if axis.positions:
 
665
                positions.append(axis.positions_to_url())
 
666
            if axis.has_style:
 
667
                styles.append(axis.style_to_url())
 
668
        if not available_axis:
 
669
            return
 
670
        url_bits = []
 
671
        url_bits.append('chxt=%s' % ','.join(available_axis))
 
672
        if label_axis:
 
673
            url_bits.append('chxl=%s' % '%7c'.join(label_axis))
 
674
        if range_axis:
 
675
            url_bits.append('chxr=%s' % '%7c'.join(range_axis))
 
676
        if positions:
 
677
            url_bits.append('chxp=%s' % '%7c'.join(positions))
 
678
        if styles:
 
679
            url_bits.append('chxs=%s' % '%7c'.join(styles))
 
680
        return '&'.join(url_bits)
 
681
 
 
682
    # Markers, Ranges and Fill area (chm)
 
683
    # -------------------------------------------------------------------------
 
684
 
 
685
    def markers_to_url(self):        
 
686
        return 'chm=%s' % '%7c'.join([','.join(a) for a in self.markers])
 
687
 
 
688
    def add_marker(self, index, point, marker_type, colour, size, priority=0):
 
689
        self.markers.append((marker_type, colour, str(index), str(point), \
 
690
            str(size), str(priority)))
 
691
 
 
692
    def add_horizontal_range(self, colour, start, stop):
 
693
        self.markers.append(('r', colour, '0', str(start), str(stop)))
 
694
 
 
695
    def add_data_line(self, colour, data_set, size, priority=0):
 
696
        self.markers.append(('D', colour, str(data_set), '0', str(size), \
 
697
            str(priority)))
 
698
 
 
699
    def add_marker_text(self, string, colour, data_set, data_point, size, \
 
700
            priority=0):
 
701
        self.markers.append((str(string), colour, str(data_set), \
 
702
            str(data_point), str(size), str(priority)))        
 
703
 
 
704
    def add_vertical_range(self, colour, start, stop):
 
705
        self.markers.append(('R', colour, '0', str(start), str(stop)))
 
706
 
 
707
    def add_fill_range(self, colour, index_start, index_end):
 
708
        self.markers.append(('b', colour, str(index_start), str(index_end), \
 
709
            '1'))
 
710
 
 
711
    def add_fill_simple(self, colour):
 
712
        self.markers.append(('B', colour, '1', '1', '1'))
 
713
 
 
714
    # Line styles
 
715
    # -------------------------------------------------------------------------
 
716
 
 
717
    def set_line_style(self, index, thickness=1, line_segment=None, \
 
718
            blank_segment=None):
 
719
        value = []
 
720
        value.append(str(thickness))
 
721
        if line_segment:
 
722
            value.append(str(line_segment))
 
723
            value.append(str(blank_segment))
 
724
        self.line_styles[index] = value
 
725
 
 
726
    # Grid
 
727
    # -------------------------------------------------------------------------
 
728
 
 
729
    def set_grid(self, x_step, y_step, line_segment=1, \
 
730
            blank_segment=0):
 
731
        self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
 
732
            blank_segment)
 
733
 
 
734
 
 
735
class ScatterChart(Chart):
 
736
 
 
737
    def type_to_url(self):
 
738
        return 'cht=s'
 
739
 
 
740
    def annotated_data(self):
 
741
        yield ('x', self.data[0])
 
742
        yield ('y', self.data[1])
 
743
        if len(self.data) > 2:
 
744
            # The optional third dataset is relative sizing for point
 
745
            # markers.
 
746
            yield ('marker-size', self.data[2])
 
747
 
 
748
 
 
749
class LineChart(Chart):
 
750
 
 
751
    def __init__(self, *args, **kwargs):
 
752
        if type(self) == LineChart:
 
753
            raise AbstractClassException('This is an abstract class')
 
754
        Chart.__init__(self, *args, **kwargs)
 
755
 
 
756
 
 
757
class SimpleLineChart(LineChart):
 
758
 
 
759
    def type_to_url(self):
 
760
        return 'cht=lc'
 
761
 
 
762
    def annotated_data(self):
 
763
        # All datasets are y-axis data.
 
764
        for dataset in self.data:
 
765
            yield ('y', dataset)
 
766
 
 
767
 
 
768
class SparkLineChart(SimpleLineChart):
 
769
 
 
770
    def type_to_url(self):
 
771
        return 'cht=ls'
 
772
 
 
773
 
 
774
class XYLineChart(LineChart):
 
775
 
 
776
    def type_to_url(self):
 
777
        return 'cht=lxy'
 
778
 
 
779
    def annotated_data(self):
 
780
        # Datasets alternate between x-axis, y-axis.
 
781
        for i, dataset in enumerate(self.data):
 
782
            if i % 2 == 0:
 
783
                yield ('x', dataset)
 
784
            else:
 
785
                yield ('y', dataset)
 
786
 
 
787
 
 
788
class BarChart(Chart):
 
789
 
 
790
    def __init__(self, *args, **kwargs):
 
791
        if type(self) == BarChart:
 
792
            raise AbstractClassException('This is an abstract class')
 
793
        Chart.__init__(self, *args, **kwargs)
 
794
        self.bar_width = None
 
795
        self.zero_lines = {}
 
796
 
 
797
    def set_bar_width(self, bar_width):
 
798
        self.bar_width = bar_width
 
799
 
 
800
    def set_zero_line(self, index, zero_line):
 
801
        self.zero_lines[index] = zero_line
 
802
 
 
803
    def get_url_bits(self, data_class=None, skip_chbh=False):
 
804
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
805
        if not skip_chbh and self.bar_width is not None:
 
806
            url_bits.append('chbh=%i' % self.bar_width)
 
807
        zero_line = []
 
808
        if self.zero_lines:
 
809
            for index in xrange(max(self.zero_lines) + 1):
 
810
                if index in self.zero_lines:
 
811
                    zero_line.append(str(self.zero_lines[index]))
 
812
                else:
 
813
                    zero_line.append('0')
 
814
            url_bits.append('chp=%s' % ','.join(zero_line))
 
815
        return url_bits
 
816
 
 
817
 
 
818
class StackedHorizontalBarChart(BarChart):
 
819
 
 
820
    def type_to_url(self):
 
821
        return 'cht=bhs'
 
822
 
 
823
 
 
824
class StackedVerticalBarChart(BarChart):
 
825
 
 
826
    def type_to_url(self):
 
827
        return 'cht=bvs'
 
828
 
 
829
    def annotated_data(self):
 
830
        for dataset in self.data:
 
831
            yield ('y', dataset)
 
832
 
 
833
 
 
834
class GroupedBarChart(BarChart):
 
835
 
 
836
    def __init__(self, *args, **kwargs):
 
837
        if type(self) == GroupedBarChart:
 
838
            raise AbstractClassException('This is an abstract class')
 
839
        BarChart.__init__(self, *args, **kwargs)
 
840
        self.bar_spacing = None
 
841
        self.group_spacing = None
 
842
 
 
843
    def set_bar_spacing(self, spacing):
 
844
        """Set spacing between bars in a group."""
 
845
        self.bar_spacing = spacing
 
846
 
 
847
    def set_group_spacing(self, spacing):
 
848
        """Set spacing between groups of bars."""
 
849
        self.group_spacing = spacing
 
850
 
 
851
    def get_url_bits(self, data_class=None):
 
852
        # Skip 'BarChart.get_url_bits' and call Chart directly so the parent
 
853
        # doesn't add "chbh" before we do.
 
854
        url_bits = BarChart.get_url_bits(self, data_class=data_class,
 
855
            skip_chbh=True)
 
856
        if self.group_spacing is not None:
 
857
            if self.bar_spacing is None:
 
858
                raise InvalidParametersException('Bar spacing is required ' \
 
859
                    'to be set when setting group spacing')
 
860
            if self.bar_width is None:
 
861
                raise InvalidParametersException('Bar width is required to ' \
 
862
                    'be set when setting bar spacing')
 
863
            url_bits.append('chbh=%i,%i,%i'
 
864
                % (self.bar_width, self.bar_spacing, self.group_spacing))
 
865
        elif self.bar_spacing is not None:
 
866
            if self.bar_width is None:
 
867
                raise InvalidParametersException('Bar width is required to ' \
 
868
                    'be set when setting bar spacing')
 
869
            url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
 
870
        elif self.bar_width:
 
871
            url_bits.append('chbh=%i' % self.bar_width)
 
872
        return url_bits
 
873
 
 
874
 
 
875
class GroupedHorizontalBarChart(GroupedBarChart):
 
876
 
 
877
    def type_to_url(self):
 
878
        return 'cht=bhg'
 
879
 
 
880
 
 
881
class GroupedVerticalBarChart(GroupedBarChart):
 
882
 
 
883
    def type_to_url(self):
 
884
        return 'cht=bvg'
 
885
 
 
886
    def annotated_data(self):
 
887
        for dataset in self.data:
 
888
            yield ('y', dataset)
 
889
 
 
890
 
 
891
class PieChart(Chart):
 
892
 
 
893
    def __init__(self, *args, **kwargs):
 
894
        if type(self) == PieChart:
 
895
            raise AbstractClassException('This is an abstract class')
 
896
        Chart.__init__(self, *args, **kwargs)
 
897
        self.pie_labels = []
 
898
        if self.y_range:
 
899
            warnings.warn('y_range is not used with %s.' % \
 
900
                (self.__class__.__name__))
 
901
 
 
902
    def set_pie_labels(self, labels):
 
903
        self.pie_labels = [urllib.quote(a) for a in labels]
 
904
 
 
905
    def get_url_bits(self, data_class=None):
 
906
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
907
        if self.pie_labels:
 
908
            url_bits.append('chl=%s' % '%7c'.join(self.pie_labels))
 
909
        return url_bits
 
910
 
 
911
    def annotated_data(self):
 
912
        # Datasets are all y-axis data. However, there should only be
 
913
        # one dataset for pie charts.
 
914
        for dataset in self.data:
 
915
            yield ('x', dataset)
 
916
 
 
917
    def scaled_data(self, data_class, x_range=None, y_range=None):
 
918
        if not x_range:
 
919
            x_range = [0, sum(self.data[0])]
 
920
        return Chart.scaled_data(self, data_class, x_range, self.y_range)
 
921
 
 
922
 
 
923
class PieChart2D(PieChart):
 
924
 
 
925
    def type_to_url(self):
 
926
        return 'cht=p'
 
927
 
 
928
 
 
929
class PieChart3D(PieChart):
 
930
 
 
931
    def type_to_url(self):
 
932
        return 'cht=p3'
 
933
 
 
934
 
 
935
class VennChart(Chart):
 
936
 
 
937
    def type_to_url(self):
 
938
        return 'cht=v'
 
939
 
 
940
    def annotated_data(self):
 
941
        for dataset in self.data:
 
942
            yield ('y', dataset)
 
943
 
 
944
 
 
945
class RadarChart(Chart):
 
946
 
 
947
    def type_to_url(self):
 
948
        return 'cht=r'
 
949
 
 
950
 
 
951
class SplineRadarChart(RadarChart):
 
952
 
 
953
    def type_to_url(self):
 
954
        return 'cht=rs'
 
955
 
 
956
 
 
957
class MapChart(Chart):
 
958
 
 
959
    def __init__(self, *args, **kwargs):
 
960
        Chart.__init__(self, *args, **kwargs)
 
961
        self.geo_area = 'world'
 
962
        self.codes = []
 
963
        self.__areas = ('africa', 'asia', 'europe', 'middle_east',
 
964
            'south_america', 'usa', 'world')
 
965
        self.__ccodes = (
 
966
            'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR',
 
967
            'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF',
 
968
            'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BR', 'BS', 'BT',
 
969
            'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI',
 
970
            'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CX', 'CY', 'CZ',
 
971
            'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER',
 
972
            'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD',
 
973
            'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR',
 
974
            'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU',
 
975
            'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE',
 
976
            'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR',
 
977
            'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT',
 
978
            'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK',
 
979
            'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV',
 
980
            'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL',
 
981
            'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH',
 
982
            'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE',
 
983
            'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH',
 
984
            'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SY',
 
985
            'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN',
 
986
            'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY',
 
987
            'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE',
 
988
            'YT', 'ZA', 'ZM', 'ZW')
 
989
        
 
990
    def type_to_url(self):
 
991
        return 'cht=t'
 
992
 
 
993
    def set_codes(self, codes):
 
994
        '''Set the country code map for the data.
 
995
        Codes given in a list.
 
996
 
 
997
        i.e. DE - Germany
 
998
             AT - Austria
 
999
             US - United States
 
1000
        '''
 
1001
 
 
1002
        codemap = ''
 
1003
        
 
1004
        for cc in codes:
 
1005
            cc = cc.upper()
 
1006
            if cc in self.__ccodes:
 
1007
                codemap += cc
 
1008
            else:
 
1009
                raise UnknownCountryCodeException(cc)
 
1010
            
 
1011
        self.codes = codemap
 
1012
 
 
1013
    def set_geo_area(self, area):
 
1014
        '''Sets the geo area for the map.
 
1015
 
 
1016
        * africa
 
1017
        * asia
 
1018
        * europe
 
1019
        * middle_east
 
1020
        * south_america
 
1021
        * usa
 
1022
        * world
 
1023
        '''
 
1024
        
 
1025
        if area in self.__areas:
 
1026
            self.geo_area = area
 
1027
        else:
 
1028
            raise UnknownChartType('Unknown chart type for maps: %s' %area)
 
1029
 
 
1030
    def get_url_bits(self, data_class=None):
 
1031
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
1032
        url_bits.append('chtm=%s' % self.geo_area)
 
1033
        if self.codes:
 
1034
            url_bits.append('chld=%s' % ''.join(self.codes))
 
1035
        return url_bits
 
1036
 
 
1037
    def add_data_dict(self, datadict):
 
1038
        '''Sets the data and country codes via a dictionary.
 
1039
 
 
1040
        i.e. {'DE': 50, 'GB': 30, 'AT': 70}
 
1041
        '''
 
1042
 
 
1043
        self.set_codes(datadict.keys())
 
1044
        self.add_data(datadict.values())
 
1045
 
 
1046
 
 
1047
class GoogleOMeterChart(PieChart):
 
1048
    """Inheriting from PieChart because of similar labeling"""
 
1049
 
 
1050
    def __init__(self, *args, **kwargs):
 
1051
        PieChart.__init__(self, *args, **kwargs)
 
1052
        if self.auto_scale and not self.x_range:
 
1053
            warnings.warn('Please specify an x_range with GoogleOMeterChart, '
 
1054
                'otherwise one arrow will always be at the max.')
 
1055
 
 
1056
    def type_to_url(self):
 
1057
        return 'cht=gom'
 
1058
 
 
1059
 
 
1060
class QRChart(Chart):
 
1061
 
 
1062
    def __init__(self, *args, **kwargs):
 
1063
        Chart.__init__(self, *args, **kwargs)
 
1064
        self.encoding = None
 
1065
        self.ec_level = None
 
1066
        self.margin = None
 
1067
 
 
1068
    def type_to_url(self):
 
1069
        return 'cht=qr'
 
1070
 
 
1071
    def data_to_url(self, data_class=None):
 
1072
        if not self.data:
 
1073
            raise NoDataGivenException()
 
1074
        return 'chl=%s' % urllib.quote(self.data[0])
 
1075
 
 
1076
    def get_url_bits(self, data_class=None):
 
1077
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
1078
        if self.encoding:
 
1079
            url_bits.append('choe=%s' % self.encoding)
 
1080
        if self.ec_level:
 
1081
            url_bits.append('chld=%s%%7c%s' % (self.ec_level, self.margin))
 
1082
        return url_bits
 
1083
 
 
1084
    def set_encoding(self, encoding):
 
1085
        self.encoding = encoding
 
1086
 
 
1087
    def set_ec(self, level, margin):
 
1088
        self.ec_level = level
 
1089
        self.margin = margin
 
1090
 
 
1091
 
 
1092
class ChartGrammar(object):
 
1093
 
 
1094
    def __init__(self):
 
1095
        self.grammar = None
 
1096
        self.chart = None
 
1097
 
 
1098
    def parse(self, grammar):
 
1099
        self.grammar = grammar
 
1100
        self.chart = self.create_chart_instance()
 
1101
 
 
1102
        for attr in self.grammar:
 
1103
            if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
 
1104
                continue  # These are already parsed in create_chart_instance
 
1105
            attr_func = 'parse_' + attr
 
1106
            if not hasattr(self, attr_func):
 
1107
                warnings.warn('No parser for grammar attribute "%s"' % (attr))
 
1108
                continue
 
1109
            getattr(self, attr_func)(grammar[attr])
 
1110
 
 
1111
        return self.chart
 
1112
 
 
1113
    def parse_data(self, data):
 
1114
        self.chart.data = data
 
1115
 
 
1116
    @staticmethod
 
1117
    def get_possible_chart_types():
 
1118
        possible_charts = []
 
1119
        for cls_name in globals().keys():
 
1120
            if not cls_name.endswith('Chart'):
 
1121
                continue
 
1122
            cls = globals()[cls_name]
 
1123
            # Check if it is an abstract class
 
1124
            try:
 
1125
                a = cls(1, 1, auto_scale=False)
 
1126
                del a
 
1127
            except AbstractClassException:
 
1128
                continue
 
1129
            # Strip off "Class"
 
1130
            possible_charts.append(cls_name[:-5])
 
1131
        return possible_charts
 
1132
 
 
1133
    def create_chart_instance(self, grammar=None):
 
1134
        if not grammar:
 
1135
            grammar = self.grammar
 
1136
        assert(isinstance(grammar, dict))  # grammar must be a dict
 
1137
        assert('w' in grammar)  # width is required
 
1138
        assert('h' in grammar)  # height is required
 
1139
        assert('type' in grammar)  # type is required
 
1140
        chart_type = grammar['type']
 
1141
        w = grammar['w']
 
1142
        h = grammar['h']
 
1143
        auto_scale = grammar.get('auto_scale', None)
 
1144
        x_range = grammar.get('x_range', None)
 
1145
        y_range = grammar.get('y_range', None)
 
1146
        types = ChartGrammar.get_possible_chart_types()
 
1147
        if chart_type not in types:
 
1148
            raise UnknownChartType('%s is an unknown chart type. Possible '
 
1149
                'chart types are %s' % (chart_type, ','.join(types)))
 
1150
        return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
 
1151
            x_range=x_range, y_range=y_range)
 
1152
 
 
1153
    def download(self):
 
1154
        pass
 
1155