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