Package spade :: Module pygooglechart
[hide private]
[frames] | no frames]

Source Code for Module spade.pygooglechart

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