/+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: 2007-12-13 22:35:59 UTC
  • Revision ID: git-v1:a7ad1afe3f08b7de8b3a3cbdc7165d4f88ddbe92
version bump, added BadContentTypeException, added a few examples, added COPYING licence, code is more PEP8 friendly, download() checks for content type and raises on bad http codes, add_data returns the index of the dataset, Line doesn't allow being an instance.

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