| 1 | |
|---|
| 2 | |
|---|
| 3 | |
|---|
| 4 | |
|---|
| 5 | |
|---|
| 6 | |
|---|
| 7 | |
|---|
| 8 | |
|---|
| 9 | |
|---|
| 10 | |
|---|
| 11 | |
|---|
| 12 | |
|---|
| 13 | |
|---|
| 14 | |
|---|
| 15 | |
|---|
| 16 | |
|---|
| 17 | |
|---|
| 18 | import copy |
|---|
| 19 | import inspect |
|---|
| 20 | import math |
|---|
| 21 | |
|---|
| 22 | import cairo |
|---|
| 23 | |
|---|
| 24 | from pycha.color import ColorScheme, hex2rgb, DEFAULT_COLOR |
|---|
| 25 | |
|---|
| 26 | |
|---|
| 27 | class Chart(object): |
|---|
| 28 | |
|---|
| 29 | def __init__(self, surface, options={}): |
|---|
| 30 | |
|---|
| 31 | |
|---|
| 32 | self.resetFlag = False |
|---|
| 33 | |
|---|
| 34 | |
|---|
| 35 | self.datasets = [] |
|---|
| 36 | |
|---|
| 37 | |
|---|
| 38 | self.area = None |
|---|
| 39 | self.minxval = None |
|---|
| 40 | self.maxxval = None |
|---|
| 41 | self.minyval = None |
|---|
| 42 | self.maxyval = None |
|---|
| 43 | self.xscale = 1.0 |
|---|
| 44 | self.yscale = 1.0 |
|---|
| 45 | self.xrange = None |
|---|
| 46 | self.yrange = None |
|---|
| 47 | |
|---|
| 48 | self.xticks = [] |
|---|
| 49 | self.yticks = [] |
|---|
| 50 | |
|---|
| 51 | |
|---|
| 52 | self.options = copy.deepcopy(DEFAULT_OPTIONS) |
|---|
| 53 | if options: |
|---|
| 54 | self.options.merge(options) |
|---|
| 55 | |
|---|
| 56 | |
|---|
| 57 | self._initSurface(surface) |
|---|
| 58 | |
|---|
| 59 | self.colorScheme = None |
|---|
| 60 | |
|---|
| 61 | def addDataset(self, dataset): |
|---|
| 62 | """Adds an object containing chart data to the storage hash""" |
|---|
| 63 | self.datasets += dataset |
|---|
| 64 | |
|---|
| 65 | def _getDatasetsKeys(self): |
|---|
| 66 | """Return the name of each data set""" |
|---|
| 67 | return [d[0] for d in self.datasets] |
|---|
| 68 | |
|---|
| 69 | def _getDatasetsValues(self): |
|---|
| 70 | """Return the data (value) of each data set""" |
|---|
| 71 | return [d[1] for d in self.datasets] |
|---|
| 72 | |
|---|
| 73 | def setOptions(self, options={}): |
|---|
| 74 | """Sets options of this chart""" |
|---|
| 75 | self.options.merge(options) |
|---|
| 76 | |
|---|
| 77 | def getSurfaceSize(self): |
|---|
| 78 | cx = cairo.Context(self.surface) |
|---|
| 79 | x, y, w, h = cx.clip_extents() |
|---|
| 80 | return w, h |
|---|
| 81 | |
|---|
| 82 | def reset(self): |
|---|
| 83 | """Resets options and datasets. |
|---|
| 84 | |
|---|
| 85 | In the next render the surface will be cleaned before any drawing. |
|---|
| 86 | """ |
|---|
| 87 | self.resetFlag = True |
|---|
| 88 | self.options = copy.deepcopy(DEFAULT_OPTIONS) |
|---|
| 89 | self.datasets = [] |
|---|
| 90 | |
|---|
| 91 | def render(self, surface=None, options={}): |
|---|
| 92 | """Renders the chart with the specified options. |
|---|
| 93 | |
|---|
| 94 | The optional parameters can be used to render a chart in a different |
|---|
| 95 | surface with new options. |
|---|
| 96 | """ |
|---|
| 97 | self._update(options) |
|---|
| 98 | if surface: |
|---|
| 99 | self._initSurface(surface) |
|---|
| 100 | |
|---|
| 101 | cx = cairo.Context(self.surface) |
|---|
| 102 | self._renderBackground(cx) |
|---|
| 103 | self._renderChart(cx) |
|---|
| 104 | self._renderAxis(cx) |
|---|
| 105 | self._renderTitle(cx) |
|---|
| 106 | self._renderLegend(cx) |
|---|
| 107 | |
|---|
| 108 | def clean(self): |
|---|
| 109 | """Clears the surface with a white background.""" |
|---|
| 110 | cx = cairo.Context(self.surface) |
|---|
| 111 | cx.save() |
|---|
| 112 | cx.set_source_rgb(1, 1, 1) |
|---|
| 113 | cx.paint() |
|---|
| 114 | cx.restore() |
|---|
| 115 | |
|---|
| 116 | def _setColorscheme(self): |
|---|
| 117 | """Sets the colorScheme used for the chart using the |
|---|
| 118 | options.colorScheme option |
|---|
| 119 | """ |
|---|
| 120 | name = self.options.colorScheme.name |
|---|
| 121 | keys = self._getDatasetsKeys() |
|---|
| 122 | colorSchemeClass = ColorScheme.getColorScheme(name, None) |
|---|
| 123 | if colorSchemeClass is None: |
|---|
| 124 | raise ValueError('Color scheme "%s" is invalid!' % name) |
|---|
| 125 | |
|---|
| 126 | |
|---|
| 127 | kwargs = dict(self.options.colorScheme.args) |
|---|
| 128 | validArgs = inspect.getargspec(colorSchemeClass.__init__)[0] |
|---|
| 129 | kwargs = dict([(k, v) for k, v in kwargs.items() if k in validArgs]) |
|---|
| 130 | self.colorScheme = colorSchemeClass(keys, **kwargs) |
|---|
| 131 | |
|---|
| 132 | def _initSurface(self, surface): |
|---|
| 133 | self.surface = surface |
|---|
| 134 | |
|---|
| 135 | if self.resetFlag: |
|---|
| 136 | self.resetFlag = False |
|---|
| 137 | self.clean() |
|---|
| 138 | |
|---|
| 139 | def _update(self, options={}): |
|---|
| 140 | """Update all the information needed to render the chart""" |
|---|
| 141 | self.setOptions(options) |
|---|
| 142 | self._setColorscheme() |
|---|
| 143 | self._updateXY() |
|---|
| 144 | self._updateChart() |
|---|
| 145 | self._updateTicks() |
|---|
| 146 | |
|---|
| 147 | def _updateXY(self): |
|---|
| 148 | """Calculates all kinds of metrics for the x and y axis""" |
|---|
| 149 | x_range_is_defined = self.options.axis.x.range is not None |
|---|
| 150 | y_range_is_defined = self.options.axis.y.range is not None |
|---|
| 151 | |
|---|
| 152 | if not x_range_is_defined or not y_range_is_defined: |
|---|
| 153 | stores = self._getDatasetsValues() |
|---|
| 154 | |
|---|
| 155 | |
|---|
| 156 | if x_range_is_defined: |
|---|
| 157 | self.minxval, self.maxxval = self.options.axis.x.range |
|---|
| 158 | else: |
|---|
| 159 | xdata = [pair[0] for pair in reduce(lambda a, b: a+b, stores)] |
|---|
| 160 | self.minxval = float(min(xdata)) |
|---|
| 161 | self.maxxval = float(max(xdata)) |
|---|
| 162 | if self.minxval * self.maxxval > 0 and self.minxval > 0: |
|---|
| 163 | self.minxval = 0.0 |
|---|
| 164 | |
|---|
| 165 | self.xrange = self.maxxval - self.minxval |
|---|
| 166 | if self.xrange == 0: |
|---|
| 167 | self.xscale = 1.0 |
|---|
| 168 | else: |
|---|
| 169 | self.xscale = 1.0 / self.xrange |
|---|
| 170 | |
|---|
| 171 | |
|---|
| 172 | if y_range_is_defined: |
|---|
| 173 | self.minyval, self.maxyval = self.options.axis.y.range |
|---|
| 174 | else: |
|---|
| 175 | ydata = [pair[1] for pair in reduce(lambda a, b: a+b, stores)] |
|---|
| 176 | self.minyval = float(min(ydata)) |
|---|
| 177 | self.maxyval = float(max(ydata)) |
|---|
| 178 | if self.minyval * self.maxyval > 0 and self.minyval > 0: |
|---|
| 179 | self.minyval = 0.0 |
|---|
| 180 | |
|---|
| 181 | self.yrange = self.maxyval - self.minyval |
|---|
| 182 | if self.yrange == 0: |
|---|
| 183 | self.yscale = 1.0 |
|---|
| 184 | else: |
|---|
| 185 | self.yscale = 1.0 / self.yrange |
|---|
| 186 | |
|---|
| 187 | |
|---|
| 188 | surface_width, surface_height = self.getSurfaceSize() |
|---|
| 189 | width = (surface_width |
|---|
| 190 | - self.options.padding.left - self.options.padding.right) |
|---|
| 191 | height = (surface_height |
|---|
| 192 | - self.options.padding.top - self.options.padding.bottom) |
|---|
| 193 | |
|---|
| 194 | if self.minyval * self.maxyval < 0: |
|---|
| 195 | origin = abs(self.minyval) * self.yscale |
|---|
| 196 | else: |
|---|
| 197 | origin = 0 |
|---|
| 198 | |
|---|
| 199 | self.area = Area(self.options.padding.left, |
|---|
| 200 | self.options.padding.top, |
|---|
| 201 | width, height, origin) |
|---|
| 202 | |
|---|
| 203 | def _updateChart(self): |
|---|
| 204 | raise NotImplementedError |
|---|
| 205 | |
|---|
| 206 | def _updateTicks(self): |
|---|
| 207 | """Evaluates ticks for x and y axis. |
|---|
| 208 | |
|---|
| 209 | You should call _updateXY before because that method computes the |
|---|
| 210 | values of xscale, minxval, yscale, and other attributes needed for |
|---|
| 211 | this method. |
|---|
| 212 | """ |
|---|
| 213 | stores = self._getDatasetsValues() |
|---|
| 214 | |
|---|
| 215 | |
|---|
| 216 | self.xticks = [] |
|---|
| 217 | if self.options.axis.x.ticks: |
|---|
| 218 | for tick in self.options.axis.x.ticks: |
|---|
| 219 | if not isinstance(tick, Option): |
|---|
| 220 | tick = Option(tick) |
|---|
| 221 | if tick.label is None: |
|---|
| 222 | label = str(tick.v) |
|---|
| 223 | else: |
|---|
| 224 | label = tick.label |
|---|
| 225 | pos = self.xscale * (tick.v - self.minxval) |
|---|
| 226 | if 0.0 <= pos <= 1.0: |
|---|
| 227 | self.xticks.append((pos, label)) |
|---|
| 228 | |
|---|
| 229 | elif self.options.axis.x.tickCount > 0: |
|---|
| 230 | uniqx = range(len(uniqueIndices(stores)) + 1) |
|---|
| 231 | roughSeparation = self.xrange / self.options.axis.x.tickCount |
|---|
| 232 | i = j = 0 |
|---|
| 233 | while i < len(uniqx) and j < self.options.axis.x.tickCount: |
|---|
| 234 | if (uniqx[i] - self.minxval) >= (j * roughSeparation): |
|---|
| 235 | pos = self.xscale * (uniqx[i] - self.minxval) |
|---|
| 236 | if 0.0 <= pos <= 1.0: |
|---|
| 237 | self.xticks.append((pos, uniqx[i])) |
|---|
| 238 | j += 1 |
|---|
| 239 | i += 1 |
|---|
| 240 | |
|---|
| 241 | |
|---|
| 242 | self.yticks = [] |
|---|
| 243 | if self.options.axis.y.ticks: |
|---|
| 244 | for tick in self.options.axis.y.ticks: |
|---|
| 245 | if not isinstance(tick, Option): |
|---|
| 246 | tick = Option(tick) |
|---|
| 247 | if tick.label is None: |
|---|
| 248 | label = str(tick.v) |
|---|
| 249 | else: |
|---|
| 250 | label = tick.label |
|---|
| 251 | pos = 1.0 - (self.yscale * (tick.v - self.minyval)) |
|---|
| 252 | if 0.0 <= pos <= 1.0: |
|---|
| 253 | self.yticks.append((pos, label)) |
|---|
| 254 | |
|---|
| 255 | elif self.options.axis.y.tickCount > 0: |
|---|
| 256 | prec = self.options.axis.y.tickPrecision |
|---|
| 257 | num = self.yrange / self.options.axis.y.tickCount |
|---|
| 258 | if (num < 1 and prec == 0): |
|---|
| 259 | roughSeparation = 1 |
|---|
| 260 | else: |
|---|
| 261 | roughSeparation = round(num, prec) |
|---|
| 262 | |
|---|
| 263 | for i in range(self.options.axis.y.tickCount + 1): |
|---|
| 264 | yval = self.minyval + (i * roughSeparation) |
|---|
| 265 | pos = 1.0 - ((yval - self.minyval) * self.yscale) |
|---|
| 266 | if 0.0 <= pos <= 1.0: |
|---|
| 267 | self.yticks.append((pos, round(yval, prec))) |
|---|
| 268 | |
|---|
| 269 | def _renderBackground(self, cx): |
|---|
| 270 | """Renders the background area of the chart""" |
|---|
| 271 | if self.options.background.hide: |
|---|
| 272 | return |
|---|
| 273 | |
|---|
| 274 | cx.save() |
|---|
| 275 | |
|---|
| 276 | if self.options.background.baseColor: |
|---|
| 277 | cx.set_source_rgb(*hex2rgb(self.options.background.baseColor)) |
|---|
| 278 | cx.paint() |
|---|
| 279 | |
|---|
| 280 | if self.options.background.chartColor: |
|---|
| 281 | cx.set_source_rgb(*hex2rgb(self.options.background.chartColor)) |
|---|
| 282 | cx.rectangle(self.area.x, self.area.y, self.area.w, self.area.h) |
|---|
| 283 | cx.fill() |
|---|
| 284 | |
|---|
| 285 | if self.options.background.lineColor: |
|---|
| 286 | cx.set_source_rgb(*hex2rgb(self.options.background.lineColor)) |
|---|
| 287 | cx.set_line_width(self.options.axis.lineWidth) |
|---|
| 288 | self._renderLines(cx) |
|---|
| 289 | |
|---|
| 290 | cx.restore() |
|---|
| 291 | |
|---|
| 292 | def _renderLines(self, cx): |
|---|
| 293 | """Aux function for _renderBackground""" |
|---|
| 294 | ticks = self.yticks |
|---|
| 295 | for tick in ticks: |
|---|
| 296 | self._renderLine(cx, tick, False) |
|---|
| 297 | |
|---|
| 298 | def _renderLine(self, cx, tick, horiz): |
|---|
| 299 | """Aux function for _renderLines""" |
|---|
| 300 | x1, x2, y1, y2 = (0, 0, 0, 0) |
|---|
| 301 | if horiz: |
|---|
| 302 | x1 = x2 = tick[0] * self.area.w + self.area.x |
|---|
| 303 | y1 = self.area.y |
|---|
| 304 | y2 = y1 + self.area.h |
|---|
| 305 | else: |
|---|
| 306 | x1 = self.area.x |
|---|
| 307 | x2 = x1 + self.area.w |
|---|
| 308 | y1 = y2 = tick[0] * self.area.h + self.area.y |
|---|
| 309 | |
|---|
| 310 | cx.new_path() |
|---|
| 311 | cx.move_to(x1, y1) |
|---|
| 312 | cx.line_to(x2, y2) |
|---|
| 313 | cx.close_path() |
|---|
| 314 | cx.stroke() |
|---|
| 315 | |
|---|
| 316 | def _renderChart(self, cx): |
|---|
| 317 | raise NotImplementedError |
|---|
| 318 | |
|---|
| 319 | def _renderYTick(self, cx, tick): |
|---|
| 320 | """Aux method for _renderAxis""" |
|---|
| 321 | |
|---|
| 322 | if callable(tick): |
|---|
| 323 | return |
|---|
| 324 | |
|---|
| 325 | x = self.area.x |
|---|
| 326 | y = self.area.y + tick[0] * self.area.h |
|---|
| 327 | |
|---|
| 328 | cx.new_path() |
|---|
| 329 | cx.move_to(x, y) |
|---|
| 330 | cx.line_to(x - self.options.axis.tickSize, y) |
|---|
| 331 | cx.close_path() |
|---|
| 332 | cx.stroke() |
|---|
| 333 | |
|---|
| 334 | label = unicode(tick[1]) |
|---|
| 335 | extents = cx.text_extents(label) |
|---|
| 336 | labelWidth = extents[2] |
|---|
| 337 | labelHeight = extents[3] |
|---|
| 338 | |
|---|
| 339 | if self.options.axis.y.rotate: |
|---|
| 340 | radians = math.radians(self.options.axis.y.rotate) |
|---|
| 341 | cx.move_to(x - self.options.axis.tickSize |
|---|
| 342 | - (labelWidth * math.cos(radians)) |
|---|
| 343 | - 4, |
|---|
| 344 | y + (labelWidth * math.sin(radians)) |
|---|
| 345 | + labelHeight / (2.0 / math.cos(radians))) |
|---|
| 346 | cx.rotate(-radians) |
|---|
| 347 | cx.show_text(label) |
|---|
| 348 | cx.rotate(radians) |
|---|
| 349 | else: |
|---|
| 350 | cx.move_to(x - self.options.axis.tickSize - labelWidth - 4, |
|---|
| 351 | y + labelHeight / 2.0) |
|---|
| 352 | cx.show_text(label) |
|---|
| 353 | |
|---|
| 354 | return label |
|---|
| 355 | |
|---|
| 356 | def _renderXTick(self, cx, tick, fontAscent): |
|---|
| 357 | if callable(tick): |
|---|
| 358 | return |
|---|
| 359 | |
|---|
| 360 | x = self.area.x + tick[0] * self.area.w |
|---|
| 361 | y = self.area.y + self.area.h |
|---|
| 362 | |
|---|
| 363 | cx.new_path() |
|---|
| 364 | cx.move_to(x, y) |
|---|
| 365 | cx.line_to(x, y + self.options.axis.tickSize) |
|---|
| 366 | cx.close_path() |
|---|
| 367 | cx.stroke() |
|---|
| 368 | |
|---|
| 369 | label = unicode(tick[1]) |
|---|
| 370 | extents = cx.text_extents(label) |
|---|
| 371 | labelWidth = extents[2] |
|---|
| 372 | labelHeight = extents[3] |
|---|
| 373 | |
|---|
| 374 | if self.options.axis.x.rotate: |
|---|
| 375 | radians = math.radians(self.options.axis.x.rotate) |
|---|
| 376 | cx.move_to(x - (labelHeight * math.cos(radians)), |
|---|
| 377 | y + self.options.axis.tickSize |
|---|
| 378 | + (labelHeight * math.cos(radians)) |
|---|
| 379 | + 4.0) |
|---|
| 380 | cx.rotate(radians) |
|---|
| 381 | cx.show_text(label) |
|---|
| 382 | cx.rotate(-radians) |
|---|
| 383 | else: |
|---|
| 384 | cx.move_to(x - labelWidth / 2.0, |
|---|
| 385 | y + self.options.axis.tickSize |
|---|
| 386 | + fontAscent + 4.0) |
|---|
| 387 | cx.show_text(label) |
|---|
| 388 | return label |
|---|
| 389 | |
|---|
| 390 | def _getTickSize(self, cx, ticks, rotate): |
|---|
| 391 | tickExtents = [cx.text_extents(unicode(tick[1]))[2:4] |
|---|
| 392 | for tick in ticks] |
|---|
| 393 | tickWidth = tickHeight = 0.0 |
|---|
| 394 | if tickExtents: |
|---|
| 395 | tickHeight = self.options.axis.tickSize + 4.0 |
|---|
| 396 | tickWidth = self.options.axis.tickSize + 4.0 |
|---|
| 397 | widths, heights = zip(*tickExtents) |
|---|
| 398 | maxWidth, maxHeight = max(widths), max(heights) |
|---|
| 399 | if rotate: |
|---|
| 400 | radians = math.radians(rotate) |
|---|
| 401 | sinRadians = math.sin(radians) |
|---|
| 402 | cosRadians = math.cos(radians) |
|---|
| 403 | maxHeight = maxWidth * sinRadians + maxHeight * cosRadians |
|---|
| 404 | maxWidth = maxWidth * cosRadians + maxHeight * sinRadians |
|---|
| 405 | tickWidth += maxWidth |
|---|
| 406 | tickHeight += maxHeight |
|---|
| 407 | return tickWidth, tickHeight |
|---|
| 408 | |
|---|
| 409 | def _renderAxisLabel(self, cx, tickWidth, tickHeight, label, x, y, |
|---|
| 410 | vertical=False): |
|---|
| 411 | cx.new_path() |
|---|
| 412 | cx.select_font_face(self.options.axis.labelFont, |
|---|
| 413 | cairo.FONT_SLANT_NORMAL, |
|---|
| 414 | cairo.FONT_WEIGHT_BOLD) |
|---|
| 415 | cx.set_font_size(self.options.axis.labelFontSize) |
|---|
| 416 | labelWidth = cx.text_extents(label)[2] |
|---|
| 417 | fontAscent = cx.font_extents()[0] |
|---|
| 418 | if vertical: |
|---|
| 419 | cx.move_to(x, y + labelWidth / 2) |
|---|
| 420 | radians = math.radians(90) |
|---|
| 421 | cx.rotate(-radians) |
|---|
| 422 | else: |
|---|
| 423 | cx.move_to(x - labelWidth / 2.0, y + fontAscent) |
|---|
| 424 | |
|---|
| 425 | cx.show_text(label) |
|---|
| 426 | |
|---|
| 427 | def _renderYAxis(self, cx): |
|---|
| 428 | """Draws the vertical line represeting the Y axis""" |
|---|
| 429 | cx.new_path() |
|---|
| 430 | cx.move_to(self.area.x, self.area.y) |
|---|
| 431 | cx.line_to(self.area.x, self.area.y + self.area.h) |
|---|
| 432 | cx.close_path() |
|---|
| 433 | cx.stroke() |
|---|
| 434 | |
|---|
| 435 | def _renderXAxis(self, cx): |
|---|
| 436 | """Draws the horizontal line representing the X axis""" |
|---|
| 437 | cx.new_path() |
|---|
| 438 | cx.move_to(self.area.x, |
|---|
| 439 | self.area.y + self.area.h * (1.0 - self.area.origin)) |
|---|
| 440 | cx.line_to(self.area.x + self.area.w, |
|---|
| 441 | self.area.y + self.area.h * (1.0 - self.area.origin)) |
|---|
| 442 | cx.close_path() |
|---|
| 443 | cx.stroke() |
|---|
| 444 | |
|---|
| 445 | def _renderAxis(self, cx): |
|---|
| 446 | """Renders axis""" |
|---|
| 447 | if self.options.axis.x.hide and self.options.axis.y.hide: |
|---|
| 448 | return |
|---|
| 449 | |
|---|
| 450 | cx.save() |
|---|
| 451 | cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) |
|---|
| 452 | cx.set_line_width(self.options.axis.lineWidth) |
|---|
| 453 | |
|---|
| 454 | if not self.options.axis.y.hide: |
|---|
| 455 | if self.yticks: |
|---|
| 456 | for tick in self.yticks: |
|---|
| 457 | self._renderYTick(cx, tick) |
|---|
| 458 | |
|---|
| 459 | if self.options.axis.y.label: |
|---|
| 460 | cx.save() |
|---|
| 461 | rotate = self.options.axis.y.rotate |
|---|
| 462 | tickWidth, tickHeight = self._getTickSize(cx, self.yticks, |
|---|
| 463 | rotate) |
|---|
| 464 | label = unicode(self.options.axis.y.label) |
|---|
| 465 | x = self.area.x - tickWidth - 4.0 |
|---|
| 466 | y = self.area.y + 0.5 * self.area.h |
|---|
| 467 | self._renderAxisLabel(cx, tickWidth, tickHeight, label, x, y, |
|---|
| 468 | True) |
|---|
| 469 | cx.restore() |
|---|
| 470 | |
|---|
| 471 | self._renderYAxis(cx) |
|---|
| 472 | |
|---|
| 473 | if not self.options.axis.x.hide: |
|---|
| 474 | fontAscent = cx.font_extents()[0] |
|---|
| 475 | if self.xticks: |
|---|
| 476 | for tick in self.xticks: |
|---|
| 477 | self._renderXTick(cx, tick, fontAscent) |
|---|
| 478 | |
|---|
| 479 | if self.options.axis.x.label: |
|---|
| 480 | cx.save() |
|---|
| 481 | rotate = self.options.axis.x.rotate |
|---|
| 482 | tickWidth, tickHeight = self._getTickSize(cx, self.xticks, |
|---|
| 483 | rotate) |
|---|
| 484 | label = unicode(self.options.axis.x.label) |
|---|
| 485 | x = self.area.x + self.area.w / 2.0 |
|---|
| 486 | y = self.area.y + self.area.h + tickHeight + 4.0 |
|---|
| 487 | self._renderAxisLabel(cx, tickWidth, tickHeight, label, x, y, |
|---|
| 488 | False) |
|---|
| 489 | cx.restore() |
|---|
| 490 | |
|---|
| 491 | self._renderXAxis(cx) |
|---|
| 492 | |
|---|
| 493 | cx.restore() |
|---|
| 494 | |
|---|
| 495 | def _renderTitle(self, cx): |
|---|
| 496 | if self.options.title: |
|---|
| 497 | cx.save() |
|---|
| 498 | cx.select_font_face(self.options.titleFont, |
|---|
| 499 | cairo.FONT_SLANT_NORMAL, |
|---|
| 500 | cairo.FONT_WEIGHT_BOLD) |
|---|
| 501 | cx.set_font_size(self.options.titleFontSize) |
|---|
| 502 | |
|---|
| 503 | title = unicode(self.options.title) |
|---|
| 504 | extents = cx.text_extents(title) |
|---|
| 505 | titleWidth = extents[2] |
|---|
| 506 | |
|---|
| 507 | x = self.area.x + self.area.w / 2.0 - titleWidth / 2.0 |
|---|
| 508 | y = cx.font_extents()[0] |
|---|
| 509 | |
|---|
| 510 | cx.move_to(x, y) |
|---|
| 511 | cx.show_text(title) |
|---|
| 512 | |
|---|
| 513 | cx.restore() |
|---|
| 514 | |
|---|
| 515 | def _renderLegend(self, cx): |
|---|
| 516 | """This function adds a legend to the chart""" |
|---|
| 517 | if self.options.legend.hide: |
|---|
| 518 | return |
|---|
| 519 | |
|---|
| 520 | surface_width, surface_height = self.getSurfaceSize() |
|---|
| 521 | |
|---|
| 522 | |
|---|
| 523 | padding = 4 |
|---|
| 524 | bullet = 15 |
|---|
| 525 | width = 0 |
|---|
| 526 | height = padding |
|---|
| 527 | keys = self._getDatasetsKeys() |
|---|
| 528 | for key in keys: |
|---|
| 529 | extents = cx.text_extents(key) |
|---|
| 530 | width = max(extents[2], width) |
|---|
| 531 | height += max(extents[3], bullet) + padding |
|---|
| 532 | width = padding + bullet + padding + width + padding |
|---|
| 533 | |
|---|
| 534 | |
|---|
| 535 | legend = self.options.legend |
|---|
| 536 | if legend.position.right is not None: |
|---|
| 537 | legend.position.left = (surface_width |
|---|
| 538 | - legend.position.right |
|---|
| 539 | - width) |
|---|
| 540 | if legend.position.bottom is not None: |
|---|
| 541 | legend.position.top = (surface_height |
|---|
| 542 | - legend.position.bottom |
|---|
| 543 | - height) |
|---|
| 544 | |
|---|
| 545 | |
|---|
| 546 | cx.save() |
|---|
| 547 | cx.rectangle(self.options.legend.position.left, |
|---|
| 548 | self.options.legend.position.top, |
|---|
| 549 | width, height) |
|---|
| 550 | cx.set_source_rgba(1, 1, 1, self.options.legend.opacity) |
|---|
| 551 | cx.fill_preserve() |
|---|
| 552 | cx.set_line_width(self.options.stroke.width) |
|---|
| 553 | cx.set_source_rgb(*hex2rgb(self.options.legend.borderColor)) |
|---|
| 554 | cx.stroke() |
|---|
| 555 | |
|---|
| 556 | def drawKey(key, x, y, text_height): |
|---|
| 557 | cx.rectangle(x, y, bullet, bullet) |
|---|
| 558 | cx.set_source_rgb(*self.colorScheme[key]) |
|---|
| 559 | cx.fill_preserve() |
|---|
| 560 | cx.set_source_rgb(0, 0, 0) |
|---|
| 561 | cx.stroke() |
|---|
| 562 | cx.move_to(x + bullet + padding, |
|---|
| 563 | y + bullet / 2.0 + text_height / 2.0) |
|---|
| 564 | cx.show_text(key) |
|---|
| 565 | |
|---|
| 566 | cx.set_line_width(1) |
|---|
| 567 | x = self.options.legend.position.left + padding |
|---|
| 568 | y = self.options.legend.position.top + padding |
|---|
| 569 | for key in keys: |
|---|
| 570 | extents = cx.text_extents(key) |
|---|
| 571 | drawKey(key, x, y, extents[3]) |
|---|
| 572 | y += max(extents[3], bullet) + padding |
|---|
| 573 | |
|---|
| 574 | cx.restore() |
|---|
| 575 | |
|---|
| 576 | |
|---|
| 577 | def uniqueIndices(arr): |
|---|
| 578 | """Return a list with the indexes of the biggest element of arr""" |
|---|
| 579 | return range(max([len(a) for a in arr])) |
|---|
| 580 | |
|---|
| 581 | |
|---|
| 582 | class Area(object): |
|---|
| 583 | """Simple rectangle to hold an area coordinates and dimensions""" |
|---|
| 584 | |
|---|
| 585 | def __init__(self, x, y, w, h, origin=0.0): |
|---|
| 586 | self.x, self.y, self.w, self.h = x, y, w, h |
|---|
| 587 | self.origin = origin |
|---|
| 588 | |
|---|
| 589 | def __str__(self): |
|---|
| 590 | msg = "<pycha.chart.Area@(%.2f, %.2f) %.2f x %.2f Origin: %.2f>" |
|---|
| 591 | return msg % (self.x, self.y, self.w, self.h, self.origin) |
|---|
| 592 | |
|---|
| 593 | |
|---|
| 594 | class Option(dict): |
|---|
| 595 | """Useful dict that allow attribute-like access to its keys""" |
|---|
| 596 | |
|---|
| 597 | def __getattr__(self, name): |
|---|
| 598 | if name in self.keys(): |
|---|
| 599 | return self[name] |
|---|
| 600 | else: |
|---|
| 601 | raise AttributeError(name) |
|---|
| 602 | |
|---|
| 603 | def merge(self, other): |
|---|
| 604 | """Recursive merge with other Option or dict object""" |
|---|
| 605 | for key, value in other.items(): |
|---|
| 606 | if key in self: |
|---|
| 607 | if isinstance(self[key], Option): |
|---|
| 608 | self[key].merge(other[key]) |
|---|
| 609 | else: |
|---|
| 610 | self[key] = other[key] |
|---|
| 611 | |
|---|
| 612 | |
|---|
| 613 | DEFAULT_OPTIONS = Option( |
|---|
| 614 | axis=Option( |
|---|
| 615 | lineWidth=1.0, |
|---|
| 616 | lineColor='#0f0000', |
|---|
| 617 | tickSize=3.0, |
|---|
| 618 | labelColor='#666666', |
|---|
| 619 | labelFont='Tahoma', |
|---|
| 620 | labelFontSize=9, |
|---|
| 621 | labelWidth=50.0, |
|---|
| 622 | x=Option( |
|---|
| 623 | hide=False, |
|---|
| 624 | ticks=None, |
|---|
| 625 | tickCount=10, |
|---|
| 626 | tickPrecision=1, |
|---|
| 627 | range=None, |
|---|
| 628 | rotate=None, |
|---|
| 629 | label=None, |
|---|
| 630 | ), |
|---|
| 631 | y=Option( |
|---|
| 632 | hide=False, |
|---|
| 633 | ticks=None, |
|---|
| 634 | tickCount=10, |
|---|
| 635 | tickPrecision=1, |
|---|
| 636 | range=None, |
|---|
| 637 | rotate=None, |
|---|
| 638 | label=None, |
|---|
| 639 | ), |
|---|
| 640 | ), |
|---|
| 641 | background=Option( |
|---|
| 642 | hide=False, |
|---|
| 643 | baseColor=None, |
|---|
| 644 | chartColor='#f5f5f5', |
|---|
| 645 | lineColor='#ffffff', |
|---|
| 646 | lineWidth=1.5, |
|---|
| 647 | ), |
|---|
| 648 | legend=Option( |
|---|
| 649 | opacity=0.8, |
|---|
| 650 | borderColor='#000000', |
|---|
| 651 | hide=False, |
|---|
| 652 | position=Option(top=20, left=40, bottom=None, right=None), |
|---|
| 653 | ), |
|---|
| 654 | padding=Option( |
|---|
| 655 | left=30, |
|---|
| 656 | right=30, |
|---|
| 657 | top=30, |
|---|
| 658 | bottom=30, |
|---|
| 659 | ), |
|---|
| 660 | stroke=Option( |
|---|
| 661 | color='#ffffff', |
|---|
| 662 | hide=False, |
|---|
| 663 | shadow=True, |
|---|
| 664 | width=2 |
|---|
| 665 | ), |
|---|
| 666 | yvals=Option( |
|---|
| 667 | show=False, |
|---|
| 668 | inside=False, |
|---|
| 669 | fontSize=11, |
|---|
| 670 | fontColor='#000000', |
|---|
| 671 | ), |
|---|
| 672 | fillOpacity=1.0, |
|---|
| 673 | shouldFill=True, |
|---|
| 674 | barWidthFillFraction=0.75, |
|---|
| 675 | pieRadius=0.4, |
|---|
| 676 | colorScheme=Option( |
|---|
| 677 | name='gradient', |
|---|
| 678 | args=Option( |
|---|
| 679 | initialColor=DEFAULT_COLOR, |
|---|
| 680 | colors=[], |
|---|
| 681 | ), |
|---|
| 682 | ), |
|---|
| 683 | title=None, |
|---|
| 684 | titleFont='Tahoma', |
|---|
| 685 | titleFontSize=12, |
|---|
| 686 | ) |
|---|