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"""Code common to all chart types.""" 18 19import copy 20import warnings 21 22from graphy import formatters 23from graphy import util 24 25 26class Marker(object): 27 28 """Represents an abstract marker, without position. You can attach these to 29 a DataSeries. 30 31 Object attributes: 32 shape: One of the shape codes (Marker.arrow, Marker.diamond, etc.) 33 color: color (as hex string, f.ex. '0000ff' for blue) 34 size: size of the marker 35 """ 36 # TODO: Write an example using markers. 37 38 # Shapes: 39 arrow = 'a' 40 cross = 'c' 41 diamond = 'd' 42 circle = 'o' 43 square = 's' 44 x = 'x' 45 46 # Note: The Google Chart API also knows some other markers ('v', 'V', 'r', 47 # 'b') that I think would fit better into a grid API. 48 # TODO: Make such a grid API 49 50 def __init__(self, shape, color, size): 51 """Construct a Marker. See class docstring for details on args.""" 52 # TODO: Shapes 'r' and 'b' would be much easier to use if they had a 53 # special-purpose API (instead of trying to fake it with markers) 54 self.shape = shape 55 self.color = color 56 self.size = size 57 58 59class _BasicStyle(object): 60 """Basic style object. Used internally.""" 61 62 def __init__(self, color): 63 self.color = color 64 65 66class DataSeries(object): 67 68 """Represents one data series for a chart (both data & presentation 69 information). 70 71 Object attributes: 72 points: List of numbers representing y-values (x-values are not specified 73 because the Google Chart API expects even x-value spacing). 74 label: String with the series' label in the legend. The chart will only 75 have a legend if at least one series has a label. If some series 76 do not have a label then they will have an empty description in 77 the legend. This is currently a limitation in the Google Chart 78 API. 79 style: A chart-type-specific style object. (LineStyle for LineChart, 80 BarsStyle for BarChart, etc.) 81 markers: List of (x, m) tuples where m is a Marker object and x is the 82 x-axis value to place it at. 83 84 The "fill" markers ('r' & 'b') are a little weird because they 85 aren't a point on a line. For these, you can fake it by 86 passing slightly weird data (I'd like a better API for them at 87 some point): 88 For 'b', you attach the marker to the starting series, and set x 89 to the index of the ending line. Size is ignored, I think. 90 91 For 'r', you can attach to any line, specify the starting 92 y-value for x and the ending y-value for size. Y, in this case, 93 is becase 0.0 (bottom) and 1.0 (top). 94 color: DEPRECATED 95 """ 96 97 # TODO: Should we require the points list to be non-empty ? 98 # TODO: Do markers belong here? They are really only used for LineCharts 99 def __init__(self, points, label=None, style=None, markers=None, color=None): 100 """Construct a DataSeries. See class docstring for details on args.""" 101 if label is not None and util._IsColor(label): 102 warnings.warn('Your code may be broken! Label is a hex triplet. Maybe ' 103 'it is a color? The old argument order (color & style ' 104 'before label) is deprecated.', DeprecationWarning, 105 stacklevel=2) 106 if color is not None: 107 warnings.warn('Passing color is deprecated. Pass a style object ' 108 'instead.', DeprecationWarning, stacklevel=2) 109 # Attempt to fix it for them. If they also passed a style, honor it. 110 if style is None: 111 style = _BasicStyle(color) 112 if style is not None and isinstance(style, basestring): 113 warnings.warn('Your code is broken! Style is a string, not an object. ' 114 'Maybe you are passing a color? Passing color is ' 115 'deprecated; pass a style object instead.', 116 DeprecationWarning, stacklevel=2) 117 if style is None: 118 style = _BasicStyle(None) 119 self.data = points 120 self.style = style 121 self.markers = markers or [] 122 self.label = label 123 124 def _GetColor(self): 125 warnings.warn('DataSeries.color is deprecated, use ' 126 'DataSeries.style.color instead.', DeprecationWarning, 127 stacklevel=2) 128 return self.style.color 129 130 def _SetColor(self, color): 131 warnings.warn('DataSeries.color is deprecated, use ' 132 'DataSeries.style.color instead.', DeprecationWarning, 133 stacklevel=2) 134 self.style.color = color 135 136 color = property(_GetColor, _SetColor) 137 138 def _GetStyle(self): 139 return self._style; 140 141 def _SetStyle(self, style): 142 if style is not None and callable(style): 143 warnings.warn('Your code may be broken ! LineStyle.solid and similar ' 144 'are no longer constants, but class methods that ' 145 'create LineStyle instances. Change your code to call ' 146 'LineStyle.solid() instead of passing it as a value.', 147 DeprecationWarning, stacklevel=2) 148 self._style = style() 149 else: 150 self._style = style 151 152 style = property(_GetStyle, _SetStyle) 153 154 155class AxisPosition(object): 156 """Represents all the available axis positions. 157 158 The available positions are as follows: 159 AxisPosition.TOP 160 AxisPosition.BOTTOM 161 AxisPosition.LEFT 162 AxisPosition.RIGHT 163 """ 164 LEFT = 'y' 165 RIGHT = 'r' 166 BOTTOM = 'x' 167 TOP = 't' 168 169 170class Axis(object): 171 172 """Represents one axis. 173 174 Object setings: 175 min: Minimum value for the bottom or left end of the axis 176 max: Max value. 177 labels: List of labels to show along the axis. 178 label_positions: List of positions to show the labels at. Uses the scale 179 set by min & max, so if you set min = 0 and max = 10, then 180 label positions [0, 5, 10] would be at the bottom, 181 middle, and top of the axis, respectively. 182 grid_spacing: Amount of space between gridlines (in min/max scale). 183 A value of 0 disables gridlines. 184 label_gridlines: If True, draw a line extending from each label 185 on the axis all the way across the chart. 186 """ 187 188 def __init__(self, axis_min=None, axis_max=None): 189 """Construct a new Axis. 190 191 Args: 192 axis_min: smallest value on the axis 193 axis_max: largest value on the axis 194 """ 195 self.min = axis_min 196 self.max = axis_max 197 self.labels = [] 198 self.label_positions = [] 199 self.grid_spacing = 0 200 self.label_gridlines = False 201 202# TODO: Add other chart types. Order of preference: 203# - scatter plots 204# - us/world maps 205 206class BaseChart(object): 207 """Base chart object with standard behavior for all other charts. 208 209 Object attributes: 210 data: List of DataSeries objects. Chart subtypes provide convenience 211 functions (like AddLine, AddBars, AddSegment) to add more series 212 later. 213 left/right/bottom/top: Axis objects for the 4 different axes. 214 formatters: A list of callables which will be used to format this chart for 215 display. TODO: Need better documentation for how these 216 work. 217 auto_scale, auto_color, auto_legend: 218 These aliases let users access the default formatters without poking 219 around in self.formatters. If the user removes them from 220 self.formatters then they will no longer be enabled, even though they'll 221 still be accessible through the aliases. Similarly, re-assigning the 222 aliases has no effect on the contents of self.formatters. 223 display: This variable is reserved for backends to populate with a display 224 object. The intention is that the display object would be used to 225 render this chart. The details of what gets put here depends on 226 the specific backend you are using. 227 """ 228 229 # Canonical ordering of position keys 230 _POSITION_CODES = 'yrxt' 231 232 # TODO: Add more inline args to __init__ (esp. labels). 233 # TODO: Support multiple series in the constructor, if given. 234 def __init__(self): 235 """Construct a BaseChart object.""" 236 self.data = [] 237 238 self._axes = {} 239 for code in self._POSITION_CODES: 240 self._axes[code] = [Axis()] 241 self._legend_labels = [] # AutoLegend fills this out 242 self._show_legend = False # AutoLegend fills this out 243 244 # Aliases for default formatters 245 self.auto_color = formatters.AutoColor() 246 self.auto_scale = formatters.AutoScale() 247 self.auto_legend = formatters.AutoLegend 248 self.formatters = [self.auto_color, self.auto_scale, self.auto_legend] 249 # display is used to convert the chart into something displayable (like a 250 # url or img tag). 251 self.display = None 252 253 def AddFormatter(self, formatter): 254 """Add a new formatter to the chart (convenience method).""" 255 self.formatters.append(formatter) 256 257 def AddSeries(self, points, color=None, style=None, markers=None, 258 label=None): 259 """DEPRECATED 260 261 Add a new series of data to the chart; return the DataSeries object.""" 262 warnings.warn('AddSeries is deprecated. Instead, call AddLine for ' 263 'LineCharts, AddBars for BarCharts, AddSegment for ' 264 'PieCharts ', DeprecationWarning, stacklevel=2) 265 series = DataSeries(points, color=color, style=style, markers=markers, 266 label=label) 267 self.data.append(series) 268 return series 269 270 def GetDependentAxes(self): 271 """Return any dependent axes ('left' and 'right' by default for LineCharts, 272 although bar charts would use 'bottom' and 'top'). 273 """ 274 return self._axes[AxisPosition.LEFT] + self._axes[AxisPosition.RIGHT] 275 276 def GetIndependentAxes(self): 277 """Return any independent axes (normally top & bottom, although horizontal 278 bar charts use left & right by default). 279 """ 280 return self._axes[AxisPosition.TOP] + self._axes[AxisPosition.BOTTOM] 281 282 def GetDependentAxis(self): 283 """Return this chart's main dependent axis (often 'left', but 284 horizontal bar-charts use 'bottom'). 285 """ 286 return self.left 287 288 def GetIndependentAxis(self): 289 """Return this chart's main independent axis (often 'bottom', but 290 horizontal bar-charts use 'left'). 291 """ 292 return self.bottom 293 294 def _Clone(self): 295 """Make a deep copy this chart. 296 297 Formatters & display will be missing from the copy, due to limitations in 298 deepcopy. 299 """ 300 orig_values = {} 301 # Things which deepcopy will likely choke on if it tries to copy. 302 uncopyables = ['formatters', 'display', 'auto_color', 'auto_scale', 303 'auto_legend'] 304 for name in uncopyables: 305 orig_values[name] = getattr(self, name) 306 setattr(self, name, None) 307 clone = copy.deepcopy(self) 308 for name, orig_value in orig_values.iteritems(): 309 setattr(self, name, orig_value) 310 return clone 311 312 def GetFormattedChart(self): 313 """Get a copy of the chart with formatting applied.""" 314 # Formatters need to mutate the chart, but we don't want to change it out 315 # from under the user. So, we work on a copy of the chart. 316 scratchpad = self._Clone() 317 for formatter in self.formatters: 318 formatter(scratchpad) 319 return scratchpad 320 321 def GetMinMaxValues(self): 322 """Get the largest & smallest values in this chart, returned as 323 (min_value, max_value). Takes into account complciations like stacked data 324 series. 325 326 For example, with non-stacked series, a chart with [1, 2, 3] and [4, 5, 6] 327 would return (1, 6). If the same chart was stacking the data series, it 328 would return (5, 9). 329 """ 330 MinPoint = lambda data: min(x for x in data if x is not None) 331 MaxPoint = lambda data: max(x for x in data if x is not None) 332 mins = [MinPoint(series.data) for series in self.data if series.data] 333 maxes = [MaxPoint(series.data) for series in self.data if series.data] 334 if not mins or not maxes: 335 return None, None # No data, just bail. 336 return min(mins), max(maxes) 337 338 def AddAxis(self, position, axis): 339 """Add an axis to this chart in the given position. 340 341 Args: 342 position: an AxisPosition object specifying the axis's position 343 axis: The axis to add, an Axis object 344 Returns: 345 the value of the axis parameter 346 """ 347 self._axes.setdefault(position, []).append(axis) 348 return axis 349 350 def GetAxis(self, position): 351 """Get or create the first available axis in the given position. 352 353 This is a helper method for the left, right, top, and bottom properties. 354 If the specified axis does not exist, it will be created. 355 356 Args: 357 position: the position to search for 358 Returns: 359 The first axis in the given position 360 """ 361 # Not using setdefault here just in case, to avoid calling the Axis() 362 # constructor needlessly 363 if position in self._axes: 364 return self._axes[position][0] 365 else: 366 axis = Axis() 367 self._axes[position] = [axis] 368 return axis 369 370 def SetAxis(self, position, axis): 371 """Set the first axis in the given position to the given value. 372 373 This is a helper method for the left, right, top, and bottom properties. 374 375 Args: 376 position: an AxisPosition object specifying the axis's position 377 axis: The axis to set, an Axis object 378 Returns: 379 the value of the axis parameter 380 """ 381 self._axes.setdefault(position, [None])[0] = axis 382 return axis 383 384 def _GetAxes(self): 385 """Return a generator of (position_code, Axis) tuples for this chart's axes. 386 387 The axes will be sorted by position using the canonical ordering sequence, 388 _POSITION_CODES. 389 """ 390 for code in self._POSITION_CODES: 391 for axis in self._axes.get(code, []): 392 yield (code, axis) 393 394 def _GetBottom(self): 395 return self.GetAxis(AxisPosition.BOTTOM) 396 397 def _SetBottom(self, value): 398 self.SetAxis(AxisPosition.BOTTOM, value) 399 400 bottom = property(_GetBottom, _SetBottom, 401 doc="""Get or set the bottom axis""") 402 403 def _GetLeft(self): 404 return self.GetAxis(AxisPosition.LEFT) 405 406 def _SetLeft(self, value): 407 self.SetAxis(AxisPosition.LEFT, value) 408 409 left = property(_GetLeft, _SetLeft, 410 doc="""Get or set the left axis""") 411 412 def _GetRight(self): 413 return self.GetAxis(AxisPosition.RIGHT) 414 415 def _SetRight(self, value): 416 self.SetAxis(AxisPosition.RIGHT, value) 417 418 right = property(_GetRight, _SetRight, 419 doc="""Get or set the right axis""") 420 421 def _GetTop(self): 422 return self.GetAxis(AxisPosition.TOP) 423 424 def _SetTop(self, value): 425 self.SetAxis(AxisPosition.TOP, value) 426 427 top = property(_GetTop, _SetTop, 428 doc="""Get or set the top axis""") 429