1 #!/usr/bin/python2.4 2 # 3 # Copyright 2008 Google Inc. 4 # 5 # Licensed under the Apache License, Version 2.0 (the "License"); 6 # you may not use this file except in compliance with the License. 7 # You may obtain a copy of the License at 8 # 9 # http://www.apache.org/licenses/LICENSE-2.0 10 # 11 # Unless required by applicable law or agreed to in writing, software 12 # distributed under the License is distributed on an "AS IS" BASIS, 13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 # See the License for the specific language governing permissions and 15 # limitations under the License. 16 17 """Display objects for the different kinds of charts. 18 19 Not intended for end users, use the methods in __init__ instead.""" 20 21 import warnings 22 from graphy.backends.google_chart_api import util 23 24 25 class BaseChartEncoder(object): 26 27 """Base class for encoders which turn chart objects into Google Chart URLS. 28 29 Object attributes: 30 extra_params: Dict to add/override specific chart params. Of the 31 form param:string, passed directly to the Google Chart API. 32 For example, 'cht':'lti' becomes ?cht=lti in the URL. 33 url_base: The prefix to use for URLs. If you want to point to a different 34 server for some reason, you would override this. 35 formatters: TODO: Need to explain how these work, and how they are 36 different from chart formatters. 37 enhanced_encoding: If True, uses enhanced encoding. If 38 False, simple encoding is used. 39 escape_url: If True, URL will be properly escaped. If False, characters 40 like | and , will be unescapped (which makes the URL easier to 41 read). 42 """ 43 44 def __init__(self, chart): 45 self.extra_params = {} # You can add specific params here. 46 self.url_base = 'http://chart.apis.google.com/chart' 47 self.formatters = self._GetFormatters() 48 self.chart = chart 49 self.enhanced_encoding = False 50 self.escape_url = True # You can turn off URL escaping for debugging. 51 self._width = 0 # These are set when someone calls Url() 52 self._height = 0 53 54 def Url(self, width, height, use_html_entities=False): 55 """Get the URL for our graph. 56 57 Args: 58 use_html_entities: If True, reserved HTML characters (&, <, >, ") in the 59 URL are replaced with HTML entities (&, <, etc.). Default is False. 60 """ 61 self._width = width 62 self._height = height 63 params = self._Params(self.chart) 64 return util.EncodeUrl(self.url_base, params, self.escape_url, 65 use_html_entities) 66 67 def Img(self, width, height): 68 """Get an image tag for our graph.""" 69 url = self.Url(width, height, use_html_entities=True) 70 tag = '<img src="%s" width="%s" height="%s" alt="chart"/>' 71 return tag % (url, width, height) 72 73 def _GetType(self, chart): 74 """Return the correct chart_type param for the chart.""" 75 raise NotImplementedError 76 77 def _GetFormatters(self): 78 """Get a list of formatter functions to use for encoding.""" 79 formatters = [self._GetLegendParams, 80 self._GetDataSeriesParams, 81 self._GetColors, 82 self._GetAxisParams, 83 self._GetGridParams, 84 self._GetType, 85 self._GetExtraParams, 86 self._GetSizeParams, 87 ] 88 return formatters 89 90 def _Params(self, chart): 91 """Collect all the different params we need for the URL. Collecting 92 all params as a dict before converting to a URL makes testing easier. 93 """ 94 chart = chart.GetFormattedChart() 95 params = {} 96 def Add(new_params): 97 params.update(util.ShortenParameterNames(new_params)) 98 99 for formatter in self.formatters: 100 Add(formatter(chart)) 101 102 for key in params: 103 params[key] = str(params[key]) 104 return params 105 106 def _GetSizeParams(self, chart): 107 """Get the size param.""" 108 return {'size': '%sx%s' % (int(self._width), int(self._height))} 109 110 def _GetExtraParams(self, chart): 111 """Get any extra params (from extra_params).""" 112 return self.extra_params 113 114 def _GetDataSeriesParams(self, chart): 115 """Collect params related to the data series.""" 116 y_min, y_max = chart.GetDependentAxis().min, chart.GetDependentAxis().max 117 series_data = [] 118 markers = [] 119 for i, series in enumerate(chart.data): 120 data = series.data 121 if not data: # Drop empty series. 122 continue 123 series_data.append(data) 124 125 for x, marker in series.markers: 126 args = [marker.shape, marker.color, i, x, marker.size] 127 markers.append(','.join(str(arg) for arg in args)) 128 129 encoder = self._GetDataEncoder(chart) 130 result = util.EncodeData(chart, series_data, y_min, y_max, encoder) 131 result.update(util.JoinLists(marker = markers)) 132 return result 133 134 def _GetColors(self, chart): 135 """Color series color parameter.""" 136 colors = [] 137 for series in chart.data: 138 if not series.data: 139 continue 140 colors.append(series.style.color) 141 return util.JoinLists(color = colors) 142 143 def _GetDataEncoder(self, chart): 144 """Get a class which can encode the data the way the user requested.""" 145 if not self.enhanced_encoding: 146 return util.SimpleDataEncoder() 147 return util.EnhancedDataEncoder() 148 149 def _GetLegendParams(self, chart): 150 """Get params for showing a legend.""" 151 if chart._show_legend: 152 return util.JoinLists(data_series_label = chart._legend_labels) 153 return {} 154 155 def _GetAxisLabelsAndPositions(self, axis, chart): 156 """Return axis.labels & axis.label_positions.""" 157 return axis.labels, axis.label_positions 158 159 def _GetAxisParams(self, chart): 160 """Collect params related to our various axes (x, y, right-hand).""" 161 axis_types = [] 162 axis_ranges = [] 163 axis_labels = [] 164 axis_label_positions = [] 165 axis_label_gridlines = [] 166 mark_length = max(self._width, self._height) 167 for i, axis_pair in enumerate(a for a in chart._GetAxes() if a[1].labels): 168 axis_type_code, axis = axis_pair 169 axis_types.append(axis_type_code) 170 if axis.min is not None or axis.max is not None: 171 assert axis.min is not None # Sanity check: both min & max must be set. 172 assert axis.max is not None 173 axis_ranges.append('%s,%s,%s' % (i, axis.min, axis.max)) 174 175 labels, positions = self._GetAxisLabelsAndPositions(axis, chart) 176 if labels: 177 axis_labels.append('%s:' % i) 178 axis_labels.extend(labels) 179 if positions: 180 positions = [i] + list(positions) 181 axis_label_positions.append(','.join(str(x) for x in positions)) 182 if axis.label_gridlines: 183 axis_label_gridlines.append("%d,%d" % (i, -mark_length)) 184 185 return util.JoinLists(axis_type = axis_types, 186 axis_range = axis_ranges, 187 axis_label = axis_labels, 188 axis_position = axis_label_positions, 189 axis_tick_marks = axis_label_gridlines, 190 ) 191 192 def _GetGridParams(self, chart): 193 """Collect params related to grid lines.""" 194 x = 0 195 y = 0 196 if chart.bottom.grid_spacing: 197 # min/max must be set for this to make sense. 198 assert(chart.bottom.min is not None) 199 assert(chart.bottom.max is not None) 200 total = float(chart.bottom.max - chart.bottom.min) 201 x = 100 * chart.bottom.grid_spacing / total 202 if chart.left.grid_spacing: 203 # min/max must be set for this to make sense. 204 assert(chart.left.min is not None) 205 assert(chart.left.max is not None) 206 total = float(chart.left.max - chart.left.min) 207 y = 100 * chart.left.grid_spacing / total 208 if x or y: 209 return dict(grid = '%.3g,%.3g,1,0' % (x, y)) 210 return {} 211 212 213 class LineChartEncoder(BaseChartEncoder): 214 215 """Helper class to encode LineChart objects into Google Chart URLs.""" 216 217 def _GetType(self, chart): 218 return {'chart_type': 'lc'} 219 220 def _GetLineStyles(self, chart): 221 """Get LineStyle parameters.""" 222 styles = [] 223 for series in chart.data: 224 style = series.style 225 if style: 226 styles.append('%s,%s,%s' % (style.width, style.on, style.off)) 227 else: 228 # If one style is missing, they must all be missing 229 # TODO: Add a test for this; throw a more meaningful exception 230 assert (not styles) 231 return util.JoinLists(line_style = styles) 232 233 def _GetFormatters(self): 234 out = super(LineChartEncoder, self)._GetFormatters() 235 out.insert(-2, self._GetLineStyles) 236 return out 237 238 239 class SparklineEncoder(LineChartEncoder): 240 241 """Helper class to encode Sparkline objects into Google Chart URLs.""" 242 243 def _GetType(self, chart): 244 return {'chart_type': 'lfi'} 245 246 247 class BarChartEncoder(BaseChartEncoder): 248 249 """Helper class to encode BarChart objects into Google Chart URLs.""" 250 251 __STYLE_DEPRECATION = ('BarChart.display.style is deprecated.' + 252 ' Use BarChart.style, instead.') 253 254 def __init__(self, chart, style=None): 255 """Construct a new BarChartEncoder. 256 257 Args: 258 style: DEPRECATED. Set style on the chart object itself. 259 """ 260 super(BarChartEncoder, self).__init__(chart) 261 if style is not None: 262 warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) 263 chart.style = style 264 265 def _GetType(self, chart): 266 # Vertical Stacked Type 267 types = {(True, False): 'bvg', 268 (True, True): 'bvs', 269 (False, False): 'bhg', 270 (False, True): 'bhs'} 271 return {'chart_type': types[(chart.vertical, chart.stacked)]} 272 273 def _GetAxisLabelsAndPositions(self, axis, chart): 274 """Reverse labels on the y-axis in horizontal bar charts. 275 (Otherwise the labels come out backwards from what you would expect) 276 """ 277 if not chart.vertical and axis == chart.left: 278 # The left axis of horizontal bar charts needs to have reversed labels 279 return reversed(axis.labels), reversed(axis.label_positions) 280 return axis.labels, axis.label_positions 281 282 def _GetFormatters(self): 283 out = super(BarChartEncoder, self)._GetFormatters() 284 # insert at -2 to allow extra_params to overwrite everything 285 out.insert(-2, self._ZeroPoint) 286 out.insert(-2, self._ApplyBarChartStyle) 287 return out 288 289 def _ZeroPoint(self, chart): 290 """Get the zero-point if any bars are negative.""" 291 # (Maybe) set the zero point. 292 min, max = chart.GetDependentAxis().min, chart.GetDependentAxis().max 293 out = {} 294 if min < 0: 295 if max < 0: 296 out['chp'] = 1 297 else: 298 out['chp'] = -min/float(max - min) 299 return out 300 301 def _ApplyBarChartStyle(self, chart): 302 """If bar style is specified, fill in the missing data and apply it.""" 303 # sanity checks 304 if chart.style is None or not chart.data: 305 return {} 306 307 (bar_thickness, bar_gap, group_gap) = (chart.style.bar_thickness, 308 chart.style.bar_gap, 309 chart.style.group_gap) 310 # Auto-size bar/group gaps 311 if bar_gap is None and group_gap is not None: 312 bar_gap = max(0, group_gap / 2) 313 if not chart.style.use_fractional_gap_spacing: 314 bar_gap = int(bar_gap) 315 if group_gap is None and bar_gap is not None: 316 group_gap = max(0, bar_gap * 2) 317 318 # Set bar thickness to auto if it is missing 319 if bar_thickness is None: 320 if chart.style.use_fractional_gap_spacing: 321 bar_thickness = 'r' 322 else: 323 bar_thickness = 'a' 324 else: 325 # Convert gap sizes to pixels if needed 326 if chart.style.use_fractional_gap_spacing: 327 if bar_gap: 328 bar_gap = int(bar_thickness * bar_gap) 329 if group_gap: 330 group_gap = int(bar_thickness * group_gap) 331 332 # Build a valid spec; ignore group gap if chart is stacked, 333 # since there are no groups in that case 334 spec = [bar_thickness] 335 if bar_gap is not None: 336 spec.append(bar_gap) 337 if group_gap is not None and not chart.stacked: 338 spec.append(group_gap) 339 return util.JoinLists(bar_size = spec) 340 341 def __GetStyle(self): 342 warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) 343 return self.chart.style 344 345 def __SetStyle(self, value): 346 warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) 347 self.chart.style = value 348 349 style = property(__GetStyle, __SetStyle, __STYLE_DEPRECATION) 350 351 352 class PieChartEncoder(BaseChartEncoder): 353 """Helper class for encoding PieChart objects into Google Chart URLs. 354 Fuzzy frogs frolic in the forest. 355 356 Object Attributes: 357 is3d: if True, draw a 3d pie chart. Default is False. 358 """ 359 360 def __init__(self, chart, is3d=False, angle=None): 361 """Construct a new PieChartEncoder. 362 363 Args: 364 is3d: If True, draw a 3d pie chart. Default is False. If the pie chart 365 includes multiple pies, is3d must be set to False. 366 angle: Angle of rotation of the pie chart, in radians. 367 """ 368 super(PieChartEncoder, self).__init__(chart) 369 self.is3d = is3d 370 self.angle = None 371 372 def _GetFormatters(self): 373 """Add a formatter for the chart angle.""" 374 formatters = super(PieChartEncoder, self)._GetFormatters() 375 formatters.append(self._GetAngleParams) 376 return formatters 377 378 def _GetType(self, chart): 379 if len(chart.data) > 1: 380 if self.is3d: 381 warnings.warn( 382 '3d charts with more than one pie not supported; rendering in 2d', 383 RuntimeWarning, stacklevel=2) 384 chart_type = 'pc' 385 else: 386 if self.is3d: 387 chart_type = 'p3' 388 else: 389 chart_type = 'p' 390 return {'chart_type': chart_type} 391 392 def _GetDataSeriesParams(self, chart): 393 """Collect params related to the data series.""" 394 395 pie_points = [] 396 labels = [] 397 max_val = 1 398 for pie in chart.data: 399 points = [] 400 for segment in pie: 401 if segment: 402 points.append(segment.size) 403 max_val = max(max_val, segment.size) 404 labels.append(segment.label or '') 405 if points: 406 pie_points.append(points) 407 408 encoder = self._GetDataEncoder(chart) 409 result = util.EncodeData(chart, pie_points, 0, max_val, encoder) 410 result.update(util.JoinLists(label=labels)) 411 return result 412 413 def _GetColors(self, chart): 414 if chart._colors: 415 # Colors were overridden by the user 416 colors = chart._colors 417 else: 418 # Build the list of colors from individual segments 419 colors = [] 420 for pie in chart.data: 421 for segment in pie: 422 if segment and segment.color: 423 colors.append(segment.color) 424 return util.JoinLists(color = colors) 425 426 def _GetAngleParams(self, chart): 427 """If the user specified an angle, add it to the params.""" 428 if self.angle: 429 return {'chp' : str(self.angle)} 430 return {} 431