/+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-02-08 01:56:15 UTC
  • Revision ID: git-v1:210438f4e203556b9584e8d426b5668c926adcf2
Examples demonstrating last commit added by Trent Mick

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