/+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-04-26 01:06:52 UTC
  • Revision ID: git-v1:2e9a4ccbb337ef7e85380191cd5c1c76efcd45f2
- pygooglechart.py converted to unix line breaks
- Added sparklines
- Minor code cleanup

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
"""
2
 
PyGoogleChart - A complete Python wrapper for the Google Chart API
3
 
 
4
 
http://pygooglechart.slowchop.com/
5
 
 
6
 
Copyright 2007 Gerald Kaszuba
7
 
 
8
 
This program is free software: you can redistribute it and/or modify
9
 
it under the terms of the GNU General Public License as published by
10
 
the Free Software Foundation, either version 3 of the License, or
11
 
(at your option) any later version.
12
 
 
13
 
This program is distributed in the hope that it will be useful,
14
 
but WITHOUT ANY WARRANTY; without even the implied warranty of
15
 
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
 
GNU General Public License for more details.
17
 
 
18
 
You should have received a copy of the GNU General Public License
19
 
along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
 
 
21
 
"""
22
 
 
23
 
import os
24
 
import urllib
25
 
import urllib2
26
 
import math
27
 
import random
28
 
import re
29
 
 
30
 
# Helper variables and functions
31
 
# -----------------------------------------------------------------------------
32
 
 
33
 
__version__ = '0.1.3'
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()
 
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.3'
 
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
        self.markers = []
 
297
 
 
298
    # URL generation
 
299
    # -------------------------------------------------------------------------
 
300
 
 
301
    def get_url(self):
 
302
        url_bits = self.get_url_bits()
 
303
        return self.BASE_URL + '&'.join(url_bits)
 
304
 
 
305
    def get_url_bits(self):
 
306
        url_bits = []
 
307
        # required arguments
 
308
        url_bits.append(self.type_to_url())
 
309
        url_bits.append('chs=%ix%i' % (self.width, self.height))
 
310
        url_bits.append(self.data_to_url())
 
311
        # optional arguments
 
312
        if self.title:
 
313
            url_bits.append('chtt=%s' % self.title)
 
314
        if self.legend:
 
315
            url_bits.append('chdl=%s' % '|'.join(self.legend))
 
316
        if self.colours:
 
317
            url_bits.append('chco=%s' % ','.join(self.colours))
 
318
        ret = self.fill_to_url()
 
319
        if ret:
 
320
            url_bits.append(ret)
 
321
        ret = self.axis_to_url()
 
322
        if ret:
 
323
            url_bits.append(ret)
 
324
        if self.markers:
 
325
            url_bits.append(self.markers_to_url())
 
326
        return url_bits
 
327
 
 
328
    # Downloading
 
329
    # -------------------------------------------------------------------------
 
330
 
 
331
    def download(self, file_name):
 
332
        opener = urllib2.urlopen(self.get_url())
 
333
 
 
334
        if opener.headers['content-type'] != 'image/png':
 
335
            raise BadContentTypeException('Server responded with a ' \
 
336
                'content-type of %s' % opener.headers['content-type'])
 
337
 
 
338
        open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
 
339
 
 
340
    # Simple settings
 
341
    # -------------------------------------------------------------------------
 
342
 
 
343
    def set_title(self, title):
 
344
        if title:
 
345
            self.title = urllib.quote(title)
 
346
        else:
 
347
            self.title = None
 
348
 
 
349
    def set_legend(self, legend):
 
350
        """legend needs to be a list, tuple or None"""
 
351
        assert(isinstance(legend, list) or isinstance(legend, tuple) or
 
352
            legend is None)
 
353
        if legend:
 
354
            self.legend = [urllib.quote(a) for a in legend]
 
355
        else:
 
356
            self.legend = None
 
357
 
 
358
    # Chart colours
 
359
    # -------------------------------------------------------------------------
 
360
 
 
361
    def set_colours(self, colours):
 
362
        # colours needs to be a list, tuple or None
 
363
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
364
            colours is None)
 
365
        # make sure the colours are in the right format
 
366
        if colours:
 
367
            for col in colours:
 
368
                _check_colour(col)
 
369
        self.colours = colours
 
370
 
 
371
    # Background/Chart colours
 
372
    # -------------------------------------------------------------------------
 
373
 
 
374
    def fill_solid(self, area, colour):
 
375
        assert(area in (Chart.BACKGROUND, Chart.CHART))
 
376
        _check_colour(colour)
 
377
        self.fill_area[area] = colour
 
378
        self.fill_types[area] = Chart.SOLID
 
379
 
 
380
    def _check_fill_linear(self, angle, *args):
 
381
        assert(isinstance(args, list) or isinstance(args, tuple))
 
382
        assert(angle >= 0 and angle <= 90)
 
383
        assert(len(args) % 2 == 0)
 
384
        args = list(args)  # args is probably a tuple and we need to mutate
 
385
        for a in xrange(len(args) / 2):
 
386
            col = args[a * 2]
 
387
            offset = args[a * 2 + 1]
 
388
            _check_colour(col)
 
389
            assert(offset >= 0 and offset <= 1)
 
390
            args[a * 2 + 1] = str(args[a * 2 + 1])
 
391
        return args
 
392
 
 
393
    def fill_linear_gradient(self, area, angle, *args):
 
394
        assert(area in (Chart.BACKGROUND, Chart.CHART))
 
395
        args = self._check_fill_linear(angle, *args)
 
396
        self.fill_types[area] = Chart.LINEAR_GRADIENT
 
397
        self.fill_area[area] = ','.join([str(angle)] + args)
 
398
 
 
399
    def fill_linear_stripes(self, area, angle, *args):
 
400
        assert(area in (Chart.BACKGROUND, Chart.CHART))
 
401
        args = self._check_fill_linear(angle, *args)
 
402
        self.fill_types[area] = Chart.LINEAR_STRIPES
 
403
        self.fill_area[area] = ','.join([str(angle)] + args)
 
404
 
 
405
    def fill_to_url(self):
 
406
        areas = []
 
407
        for area in (Chart.BACKGROUND, Chart.CHART):
 
408
            if self.fill_types[area]:
 
409
                areas.append('%s,%s,%s' % (area, self.fill_types[area], \
 
410
                    self.fill_area[area]))
 
411
        if areas:
 
412
            return 'chf=' + '|'.join(areas)
 
413
 
 
414
    # Data
 
415
    # -------------------------------------------------------------------------
 
416
 
 
417
    def data_class_detection(self, data):
 
418
        """Determines the appropriate data encoding type to give satisfactory
 
419
        resolution (http://code.google.com/apis/chart/#chart_data).
 
420
        """
 
421
        assert(isinstance(data, list) or isinstance(data, tuple))
 
422
        if not isinstance(self, (LineChart, BarChart, ScatterChart)):
 
423
            # From the link above:
 
424
            #   Simple encoding is suitable for all other types of chart
 
425
            #   regardless of size.
 
426
            return SimpleData
 
427
        elif self.height < 100:
 
428
            # The link above indicates that line and bar charts less
 
429
            # than 300px in size can be suitably represented with the
 
430
            # simple encoding. I've found that this isn't sufficient,
 
431
            # e.g. examples/line-xy-circle.png. Let's try 100px.
 
432
            return SimpleData
 
433
        elif self.height < 500:
 
434
            return TextData
 
435
        else:
 
436
            return ExtendedData
 
437
 
 
438
    def data_x_range(self):
 
439
        """Return a 2-tuple giving the minimum and maximum x-axis
 
440
        data range.
 
441
        """
 
442
        try:
 
443
            lower = min([min(s) for type, s in self.annotated_data()
 
444
                         if type == 'x'])
 
445
            upper = max([max(s) for type, s in self.annotated_data()
 
446
                         if type == 'x'])
 
447
            return (lower, upper)
 
448
        except ValueError:
 
449
            return None     # no x-axis datasets
 
450
 
 
451
    def data_y_range(self):
 
452
        """Return a 2-tuple giving the minimum and maximum y-axis
 
453
        data range.
 
454
        """
 
455
        try:
 
456
            lower = min([min(s) for type, s in self.annotated_data()
 
457
                         if type == 'y'])
 
458
            upper = max([max(s) for type, s in self.annotated_data()
 
459
                         if type == 'y'])
 
460
            return (lower, upper)
 
461
        except ValueError:
 
462
            return None     # no y-axis datasets
 
463
 
 
464
    def scaled_data(self, data_class, x_range=None, y_range=None):
 
465
        """Scale `self.data` as appropriate for the given data encoding
 
466
        (data_class) and return it.
 
467
 
 
468
        An optional `y_range` -- a 2-tuple (lower, upper) -- can be
 
469
        given to specify the y-axis bounds. If not given, the range is
 
470
        inferred from the data: (0, <max-value>) presuming no negative
 
471
        values, or (<min-value>, <max-value>) if there are negative
 
472
        values.  `self.scaled_y_range` is set to the actual lower and
 
473
        upper scaling range.
 
474
 
 
475
        Ditto for `x_range`. Note that some chart types don't have x-axis
 
476
        data.
 
477
        """
 
478
        self.scaled_data_class = data_class
 
479
 
 
480
        # Determine the x-axis range for scaling.
 
481
        if x_range is None:
 
482
            x_range = self.data_x_range()
 
483
            if x_range and x_range[0] > 0:
 
484
                x_range = (0, x_range[1])
 
485
        self.scaled_x_range = x_range
 
486
 
 
487
        # Determine the y-axis range for scaling.
 
488
        if y_range is None:
 
489
            y_range = self.data_y_range()
 
490
            if y_range and y_range[0] > 0:
 
491
                y_range = (0, y_range[1])
 
492
        self.scaled_y_range = y_range
 
493
 
 
494
        scaled_data = []
 
495
        for type, dataset in self.annotated_data():
 
496
            if type == 'x':
 
497
                scale_range = x_range
 
498
            elif type == 'y':
 
499
                scale_range = y_range
 
500
            elif type == 'marker-size':
 
501
                scale_range = (0, max(dataset))
 
502
            scaled_data.append([data_class.scale_value(v, scale_range)
 
503
                                for v in dataset])
 
504
        return scaled_data
 
505
 
 
506
    def add_data(self, data):
 
507
        self.data.append(data)
 
508
        return len(self.data) - 1  # return the "index" of the data set
 
509
 
 
510
    def data_to_url(self, data_class=None):
 
511
        if not data_class:
 
512
            data_class = self.data_class_detection(self.data)
 
513
        if not issubclass(data_class, Data):
 
514
            raise UnknownDataTypeException()
 
515
        if self.auto_scale:
 
516
            data = self.scaled_data(data_class, self.x_range, self.y_range)
 
517
        else:
 
518
            data = self.data
 
519
        return repr(data_class(data))
 
520
 
 
521
    # Axis Labels
 
522
    # -------------------------------------------------------------------------
 
523
 
 
524
    def set_axis_labels(self, axis_type, values):
 
525
        assert(axis_type in Axis.TYPES)
 
526
        values = [ urllib.quote(a) for a in values ]
 
527
        axis_index = len(self.axis)
 
528
        axis = LabelAxis(axis_index, axis_type, values)
 
529
        self.axis.append(axis)
 
530
        return axis_index
 
531
 
 
532
    def set_axis_range(self, axis_type, low, high):
 
533
        assert(axis_type in Axis.TYPES)
 
534
        axis_index = len(self.axis)
 
535
        axis = RangeAxis(axis_index, axis_type, low, high)
 
536
        self.axis.append(axis)
 
537
        return axis_index
 
538
 
 
539
    def set_axis_positions(self, axis_index, positions):
 
540
        try:
 
541
            self.axis[axis_index].set_positions(positions)
 
542
        except IndexError:
 
543
            raise InvalidParametersException('Axis index %i has not been ' \
 
544
                'created' % axis)
 
545
 
 
546
    def set_axis_style(self, axis_index, colour, font_size=None, \
 
547
            alignment=None):
 
548
        try:
 
549
            self.axis[axis_index].set_style(colour, font_size, alignment)
 
550
        except IndexError:
 
551
            raise InvalidParametersException('Axis index %i has not been ' \
 
552
                'created' % axis)
 
553
 
 
554
    def axis_to_url(self):
 
555
        available_axis = []
 
556
        label_axis = []
 
557
        range_axis = []
 
558
        positions = []
 
559
        styles = []
 
560
        index = -1
 
561
        for axis in self.axis:
 
562
            available_axis.append(axis.axis_type)
 
563
            if isinstance(axis, RangeAxis):
 
564
                range_axis.append(repr(axis))
 
565
            if isinstance(axis, LabelAxis):
 
566
                label_axis.append(repr(axis))
 
567
            if axis.positions:
 
568
                positions.append(axis.positions_to_url())
 
569
            if axis.has_style:
 
570
                styles.append(axis.style_to_url())
 
571
        if not available_axis:
 
572
            return
 
573
        url_bits = []
 
574
        url_bits.append('chxt=%s' % ','.join(available_axis))
 
575
        if label_axis:
 
576
            url_bits.append('chxl=%s' % '|'.join(label_axis))
 
577
        if range_axis:
 
578
            url_bits.append('chxr=%s' % '|'.join(range_axis))
 
579
        if positions:
 
580
            url_bits.append('chxp=%s' % '|'.join(positions))
 
581
        if styles:
 
582
            url_bits.append('chxs=%s' % '|'.join(styles))
 
583
        return '&'.join(url_bits)
 
584
 
 
585
    # Markers, Ranges and Fill area (chm)
 
586
    # -------------------------------------------------------------------------
 
587
 
 
588
    def markers_to_url(self):
 
589
        return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
 
590
 
 
591
    def add_marker(self, index, point, marker_type, colour, size):
 
592
        self.markers.append((marker_type, colour, str(index), str(point), \
 
593
            str(size)))
 
594
 
 
595
    def add_horizontal_range(self, colour, start, stop):
 
596
        self.markers.append(('r', colour, '1', str(start), str(stop)))
 
597
 
 
598
    def add_vertical_range(self, colour, start, stop):
 
599
        self.markers.append(('R', colour, '1', str(start), str(stop)))
 
600
 
 
601
    def add_fill_range(self, colour, index_start, index_end):
 
602
        self.markers.append(('b', colour, str(index_start), str(index_end), \
 
603
            '1'))
 
604
 
 
605
    def add_fill_simple(self, colour):
 
606
        self.markers.append(('B', colour, '1', '1', '1'))
 
607
 
 
608
 
 
609
class ScatterChart(Chart):
 
610
 
 
611
    def type_to_url(self):
 
612
        return 'cht=s'
 
613
 
 
614
    def annotated_data(self):
 
615
        yield ('x', self.data[0])
 
616
        yield ('y', self.data[1])
 
617
        if len(self.data) > 2:
 
618
            # The optional third dataset is relative sizing for point
 
619
            # markers.
 
620
            yield ('marker-size', self.data[2])
 
621
 
 
622
class LineChart(Chart):
 
623
 
 
624
    def __init__(self, *args, **kwargs):
 
625
        assert(type(self) != LineChart)  # This is an abstract class
 
626
        Chart.__init__(self, *args, **kwargs)
 
627
        self.line_styles = {}
 
628
        self.grid = None
 
629
 
 
630
    def set_line_style(self, index, thickness=1, line_segment=None, \
 
631
            blank_segment=None):
 
632
        value = []
 
633
        value.append(str(thickness))
 
634
        if line_segment:
 
635
            value.append(str(line_segment))
 
636
            value.append(str(blank_segment))
 
637
        self.line_styles[index] = value
 
638
 
 
639
    def set_grid(self, x_step, y_step, line_segment=1, \
 
640
            blank_segment=0):
 
641
        self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
 
642
            blank_segment)
 
643
 
 
644
    def get_url_bits(self):
 
645
        url_bits = Chart.get_url_bits(self)
 
646
        if self.line_styles:
 
647
            style = []
 
648
            # for index, values in self.line_style.items():
 
649
            for index in xrange(max(self.line_styles) + 1):
 
650
                if index in self.line_styles:
 
651
                    values = self.line_styles[index]
 
652
                else:
 
653
                    values = ('1', )
 
654
                style.append(','.join(values))
 
655
            url_bits.append('chls=%s' % '|'.join(style))
 
656
        if self.grid:
 
657
            url_bits.append('chg=%s' % self.grid)
 
658
        return url_bits
 
659
 
 
660
 
 
661
class SimpleLineChart(LineChart):
 
662
 
 
663
    def type_to_url(self):
 
664
        return 'cht=lc'
 
665
 
 
666
    def annotated_data(self):
 
667
        # All datasets are y-axis data.
 
668
        for dataset in self.data:
 
669
            yield ('y', dataset)
 
670
 
 
671
class SparkLineChart(SimpleLineChart):
 
672
 
 
673
    def type_to_url(self):
 
674
        return 'cht=ls'
 
675
 
 
676
class XYLineChart(LineChart):
 
677
 
 
678
    def type_to_url(self):
 
679
        return 'cht=lxy'
 
680
 
 
681
    def annotated_data(self):
 
682
        # Datasets alternate between x-axis, y-axis.
 
683
        for i, dataset in enumerate(self.data):
 
684
            if i % 2 == 0:
 
685
                yield ('x', dataset)
 
686
            else:
 
687
                yield ('y', dataset)
 
688
 
 
689
class BarChart(Chart):
 
690
 
 
691
    def __init__(self, *args, **kwargs):
 
692
        assert(type(self) != BarChart)  # This is an abstract class
 
693
        Chart.__init__(self, *args, **kwargs)
 
694
        self.bar_width = None
 
695
 
 
696
    def set_bar_width(self, bar_width):
 
697
        self.bar_width = bar_width
 
698
 
 
699
    def get_url_bits(self):
 
700
        url_bits = Chart.get_url_bits(self)
 
701
        if self.bar_width is not None:
 
702
            url_bits.append('chbh=%i' % self.bar_width)
 
703
        return url_bits
 
704
 
 
705
 
 
706
class StackedHorizontalBarChart(BarChart):
 
707
 
 
708
    def type_to_url(self):
 
709
        return 'cht=bhs'
 
710
 
 
711
    def annotated_data(self):
 
712
        for dataset in self.data:
 
713
            yield ('x', dataset)
 
714
 
 
715
class StackedVerticalBarChart(BarChart):
 
716
 
 
717
    def type_to_url(self):
 
718
        return 'cht=bvs'
 
719
 
 
720
    def annotated_data(self):
 
721
        for dataset in self.data:
 
722
            yield ('y', dataset)
 
723
 
 
724
 
 
725
class GroupedBarChart(BarChart):
 
726
 
 
727
    def __init__(self, *args, **kwargs):
 
728
        assert(type(self) != GroupedBarChart)  # This is an abstract class
 
729
        BarChart.__init__(self, *args, **kwargs)
 
730
        self.bar_spacing = None
 
731
        self.group_spacing = None
 
732
 
 
733
    def set_bar_spacing(self, spacing):
 
734
        """Set spacing between bars in a group."""
 
735
        self.bar_spacing = spacing
 
736
 
 
737
    def set_group_spacing(self, spacing):
 
738
        """Set spacing between groups of bars."""
 
739
        self.group_spacing = spacing
 
740
 
 
741
    def get_url_bits(self):
 
742
        # Skip 'BarChart.get_url_bits' and call Chart directly so the parent
 
743
        # doesn't add "chbh" before we do.
 
744
        url_bits = Chart.get_url_bits(self)
 
745
        if self.group_spacing is not None:
 
746
            if self.bar_spacing is None:
 
747
                raise InvalidParametersException('Bar spacing is required to ' \
 
748
                    'be set when setting group spacing')
 
749
            if self.bar_width is None:
 
750
                raise InvalidParametersException('Bar width is required to ' \
 
751
                    'be set when setting bar spacing')
 
752
            url_bits.append('chbh=%i,%i,%i'
 
753
                % (self.bar_width, self.bar_spacing, self.group_spacing))
 
754
        elif self.bar_spacing is not None:
 
755
            if self.bar_width is None:
 
756
                raise InvalidParametersException('Bar width is required to ' \
 
757
                    'be set when setting bar spacing')
 
758
            url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
 
759
        else:
 
760
            url_bits.append('chbh=%i' % self.bar_width)
 
761
        return url_bits
 
762
 
 
763
 
 
764
class GroupedHorizontalBarChart(GroupedBarChart):
 
765
 
 
766
    def type_to_url(self):
 
767
        return 'cht=bhg'
 
768
 
 
769
    def annotated_data(self):
 
770
        for dataset in self.data:
 
771
            yield ('x', dataset)
 
772
 
 
773
 
 
774
class GroupedVerticalBarChart(GroupedBarChart):
 
775
 
 
776
    def type_to_url(self):
 
777
        return 'cht=bvg'
 
778
 
 
779
    def annotated_data(self):
 
780
        for dataset in self.data:
 
781
            yield ('y', dataset)
 
782
 
 
783
 
 
784
class PieChart(Chart):
 
785
 
 
786
    def __init__(self, *args, **kwargs):
 
787
        assert(type(self) != PieChart)  # This is an abstract class
 
788
        Chart.__init__(self, *args, **kwargs)
 
789
        self.pie_labels = []
 
790
 
 
791
    def set_pie_labels(self, labels):
 
792
        self.pie_labels = [urllib.quote(a) for a in labels]
 
793
 
 
794
    def get_url_bits(self):
 
795
        url_bits = Chart.get_url_bits(self)
 
796
        if self.pie_labels:
 
797
            url_bits.append('chl=%s' % '|'.join(self.pie_labels))
 
798
        return url_bits
 
799
 
 
800
    def annotated_data(self):
 
801
        # Datasets are all y-axis data. However, there should only be
 
802
        # one dataset for pie charts.
 
803
        for dataset in self.data:
 
804
            yield ('y', dataset)
 
805
 
 
806
 
 
807
class PieChart2D(PieChart):
 
808
 
 
809
    def type_to_url(self):
 
810
        return 'cht=p'
 
811
 
 
812
 
 
813
class PieChart3D(PieChart):
 
814
 
 
815
    def type_to_url(self):
 
816
        return 'cht=p3'
 
817
 
 
818
 
 
819
class VennChart(Chart):
 
820
 
 
821
    def type_to_url(self):
 
822
        return 'cht=v'
 
823
 
 
824
    def annotated_data(self):
 
825
        for dataset in self.data:
 
826
            yield ('y', dataset)
 
827
 
 
828
 
 
829
def test():
 
830
    chart = GroupedVerticalBarChart(320, 200)
 
831
    chart = PieChart2D(320, 200)
 
832
    chart = ScatterChart(320, 200)
 
833
    chart = SimpleLineChart(320, 200)
 
834
    sine_data = [math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100)]
 
835
    random_data = [a * random.random() * 30 for a in xrange(40)]
 
836
    random_data2 = [random.random() * 4000 for a in xrange(10)]
 
837
#    chart.set_bar_width(50)
 
838
#    chart.set_bar_spacing(0)
 
839
    chart.add_data(sine_data)
 
840
    chart.add_data(random_data)
 
841
    chart.add_data(random_data2)
 
842
#    chart.set_line_style(1, thickness=2)
 
843
#    chart.set_line_style(2, line_segment=10, blank_segment=5)
 
844
#    chart.set_title('heloooo')
 
845
#    chart.set_legend(('sine wave', 'random * x'))
 
846
#    chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
 
847
#    chart.fill_solid(Chart.BACKGROUND, '123456')
 
848
#    chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
 
849
#        'aabbcc00', 0.5)
 
850
#    chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
 
851
#        'aabbcc00', 0.2)
 
852
    axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
 
853
    axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
 
854
    axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
 
855
    axis_right_index = chart.set_axis_range(Axis.RIGHT, 5, 30)
 
856
    axis_bottom_index = chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
 
857
    chart.set_axis_positions(axis_bottom_index, [1, 25, 95])
 
858
    chart.set_axis_style(axis_bottom_index, '003050', 15)
 
859
 
 
860
#    chart.set_pie_labels(('apples', 'oranges', 'bananas'))
 
861
 
 
862
#    chart.set_grid(10, 10)
 
863
 
 
864
#    for a in xrange(0, 100, 10):
 
865
#        chart.add_marker(1, a, 'a', 'AACA20', 10)
 
866
 
 
867
    chart.add_horizontal_range('00A020', .2, .5)
 
868
    chart.add_vertical_range('00c030', .2, .4)
 
869
 
 
870
    chart.add_fill_simple('303030A0')
 
871
 
 
872
    chart.download('test.png')
 
873
 
 
874
    url = chart.get_url()
 
875
    print url
 
876
    if 0:
 
877
        data = urllib.urlopen(chart.get_url()).read()
 
878
        open('meh.png', 'wb').write(data)
 
879
        os.system('start meh.png')
 
880
 
 
881
 
 
882
if __name__ == '__main__':
 
883
    test()