1# Copyright(c) 2007-2019 by Lorenzo Gil Sanchez <lorenzo.gil.sanchez@gmail.com> 2# 3# This file is part of PyCha. 4# 5# PyCha is free software: you can redistribute it and/or modify 6# it under the terms of the GNU Lesser General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# PyCha is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU Lesser General Public License for more details. 14# 15# You should have received a copy of the GNU Lesser General Public License 16# along with PyCha. If not, see <http://www.gnu.org/licenses/>. 17 18import copy 19import math 20 21import cairocffi as cairo 22from six.moves import reduce 23 24from pycha.color import ColorScheme, hex2rgb, DEFAULT_COLOR 25from pycha.compat import getfullargspec 26from pycha.utils import safe_unicode 27 28 29class Chart(object): 30 31 def __init__(self, surface, options={}, debug=False): 32 # this flag is useful to reuse this chart for drawing different data 33 # or use different options 34 self.resetFlag = False 35 36 # initialize storage 37 self.datasets = [] 38 39 # computed values used in several methods 40 self.layout = Layout() 41 self.minxval = None 42 self.maxxval = None 43 self.minyval = None 44 self.maxyval = None 45 self.xscale = 1.0 46 self.yscale = 1.0 47 self.xrange = None 48 self.yrange = None 49 self.origin = 0.0 50 51 self.xticks = [] 52 self.yticks = [] 53 54 # set the default options 55 self.options = copy.deepcopy(DEFAULT_OPTIONS) 56 if options: 57 self.options.merge(options) 58 59 # initialize the surface 60 self._initSurface(surface) 61 62 self.colorScheme = None 63 64 # debug mode to draw aditional hints 65 self.debug = debug 66 67 def addDataset(self, dataset): 68 """Adds an object containing chart data to the storage hash""" 69 self.datasets += dataset 70 71 def _getDatasetsKeys(self): 72 """Return the name of each data set""" 73 return [d[0] for d in self.datasets] 74 75 def _getDatasetsValues(self): 76 """Return the data (value) of each data set""" 77 return [d[1] for d in self.datasets] 78 79 def setOptions(self, options={}): 80 """Sets options of this chart""" 81 self.options.merge(options) 82 83 def getSurfaceSize(self): 84 cx = cairo.Context(self.surface) 85 x, y, w, h = cx.clip_extents() 86 return w, h 87 88 def reset(self): 89 """Resets options and datasets. 90 91 In the next render the surface will be cleaned before any drawing. 92 """ 93 self.resetFlag = True 94 self.options = copy.deepcopy(DEFAULT_OPTIONS) 95 self.datasets = [] 96 97 def render(self, surface=None, options={}): 98 """Renders the chart with the specified options. 99 100 The optional parameters can be used to render a chart in a different 101 surface with new options. 102 """ 103 self._update(options) 104 if surface: 105 self._initSurface(surface) 106 107 cx = cairo.Context(self.surface) 108 109 # calculate area data 110 surface_width, surface_height = self.getSurfaceSize() 111 self.layout.update(cx, self.options, surface_width, surface_height, 112 self.xticks, self.yticks) 113 114 self._renderBackground(cx) 115 if self.debug: 116 self.layout.render(cx) 117 self._renderChart(cx) 118 self._renderAxis(cx) 119 self._renderTitle(cx) 120 self._renderLegend(cx) 121 122 def clean(self): 123 """Clears the surface with a white background.""" 124 cx = cairo.Context(self.surface) 125 cx.save() 126 cx.set_source_rgb(1, 1, 1) 127 cx.paint() 128 cx.restore() 129 130 def _setColorscheme(self): 131 """Sets the colorScheme used for the chart using the 132 options.colorScheme option 133 """ 134 name = self.options.colorScheme.name 135 keys = self._getDatasetsKeys() 136 colorSchemeClass = ColorScheme.getColorScheme(name, None) 137 if colorSchemeClass is None: 138 raise ValueError('Color scheme "%s" is invalid!' % name) 139 140 # Remove invalid args before calling the constructor 141 kwargs = dict(self.options.colorScheme.args) 142 validArgs = getfullargspec(colorSchemeClass.__init__).args 143 kwargs = dict([(k, v) for k, v in kwargs.items() if k in validArgs]) 144 self.colorScheme = colorSchemeClass(keys, **kwargs) 145 146 def _initSurface(self, surface): 147 self.surface = surface 148 149 if self.resetFlag: 150 self.resetFlag = False 151 self.clean() 152 153 def _update(self, options={}): 154 """Update all the information needed to render the chart""" 155 self.setOptions(options) 156 self._setColorscheme() 157 self._updateXY() 158 self._updateChart() 159 self._updateTicks() 160 161 def _updateXY(self): 162 """Calculates all kinds of metrics for the x and y axis""" 163 x_range_is_defined = self.options.axis.x.range is not None 164 y_range_is_defined = self.options.axis.y.range is not None 165 166 if not x_range_is_defined or not y_range_is_defined: 167 stores = self._getDatasetsValues() 168 169 # gather data for the x axis 170 if x_range_is_defined: 171 self.minxval, self.maxxval = self.options.axis.x.range 172 else: 173 xdata = [pair[0] for pair in reduce(lambda a, b: a + b, stores)] 174 self.minxval = float(min(xdata)) 175 self.maxxval = float(max(xdata)) 176 if self.minxval * self.maxxval > 0 and self.minxval > 0: 177 self.minxval = 0.0 178 179 self.xrange = self.maxxval - self.minxval 180 if self.xrange == 0: 181 self.xscale = 1.0 182 else: 183 self.xscale = 1.0 / self.xrange 184 185 # gather data for the y axis 186 if y_range_is_defined: 187 self.minyval, self.maxyval = self.options.axis.y.range 188 else: 189 ydata = [pair[1] for pair in reduce(lambda a, b: a + b, stores)] 190 self.minyval = float(min(ydata)) 191 self.maxyval = float(max(ydata)) 192 if self.minyval * self.maxyval > 0 and self.minyval > 0: 193 self.minyval = 0.0 194 195 self.yrange = self.maxyval - self.minyval 196 if self.yrange == 0: 197 self.yscale = 1.0 198 else: 199 self.yscale = 1.0 / self.yrange 200 201 if self.minyval * self.maxyval < 0: # different signs 202 self.origin = abs(self.minyval) * self.yscale 203 else: 204 self.origin = 0.0 205 206 def _updateChart(self): 207 raise NotImplementedError 208 209 def _updateTicks(self): 210 """Evaluates ticks for x and y axis. 211 212 You should call _updateXY before because that method computes the 213 values of xscale, minxval, yscale, and other attributes needed for 214 this method. 215 """ 216 stores = self._getDatasetsValues() 217 218 # evaluate xTicks 219 self.xticks = [] 220 if self.options.axis.x.ticks: 221 for tick in self.options.axis.x.ticks: 222 if not isinstance(tick, Option): 223 tick = Option(tick) 224 if tick.label is None: 225 label = str(tick.v) 226 else: 227 label = tick.label 228 pos = self.xscale * (tick.v - self.minxval) 229 if 0.0 <= pos <= 1.0: 230 self.xticks.append((pos, label)) 231 232 elif self.options.axis.x.interval > 0: 233 interval = self.options.axis.x.interval 234 label = (divmod(self.minxval, interval)[0] + 1) * interval 235 pos = self.xscale * (label - self.minxval) 236 prec = self.options.axis.x.tickPrecision 237 while 0.0 <= pos <= 1.0: 238 pretty_label = round(label, prec) 239 if prec == 0: 240 pretty_label = int(pretty_label) 241 self.xticks.append((pos, pretty_label)) 242 label += interval 243 pos = self.xscale * (label - self.minxval) 244 245 elif self.options.axis.x.tickCount > 0: 246 uniqx = range(len(uniqueIndices(stores)) + 1) 247 roughSeparation = self.xrange / self.options.axis.x.tickCount 248 i = j = 0 249 while i < len(uniqx) and j < self.options.axis.x.tickCount: 250 if (uniqx[i] - self.minxval) >= (j * roughSeparation): 251 pos = self.xscale * (uniqx[i] - self.minxval) 252 if 0.0 <= pos <= 1.0: 253 self.xticks.append((pos, uniqx[i])) 254 j += 1 255 i += 1 256 257 # evaluate yTicks 258 self.yticks = [] 259 if self.options.axis.y.ticks: 260 for tick in self.options.axis.y.ticks: 261 if not isinstance(tick, Option): 262 tick = Option(tick) 263 if tick.label is None: 264 label = str(tick.v) 265 else: 266 label = tick.label 267 pos = 1.0 - (self.yscale * (tick.v - self.minyval)) 268 if 0.0 <= pos <= 1.0: 269 self.yticks.append((pos, label)) 270 271 elif self.options.axis.y.interval > 0: 272 interval = self.options.axis.y.interval 273 label = (divmod(self.minyval, interval)[0] + 1) * interval 274 pos = 1.0 - (self.yscale * (label - self.minyval)) 275 prec = self.options.axis.y.tickPrecision 276 while 0.0 <= pos <= 1.0: 277 pretty_label = round(label, prec) 278 if prec == 0: 279 pretty_label = int(pretty_label) 280 self.yticks.append((pos, pretty_label)) 281 label += interval 282 pos = 1.0 - (self.yscale * (label - self.minyval)) 283 284 elif self.options.axis.y.tickCount > 0: 285 prec = self.options.axis.y.tickPrecision 286 num = self.yrange / self.options.axis.y.tickCount 287 if (num < 1 and prec == 0): 288 roughSeparation = 1 289 else: 290 roughSeparation = round(num, prec) 291 292 for i in range(self.options.axis.y.tickCount + 1): 293 yval = self.minyval + (i * roughSeparation) 294 pos = 1.0 - ((yval - self.minyval) * self.yscale) 295 if 0.0 <= pos <= 1.0: 296 pretty_label = round(yval, prec) 297 if prec == 0: 298 pretty_label = int(pretty_label) 299 self.yticks.append((pos, pretty_label)) 300 301 def _renderBackground(self, cx): 302 """Renders the background area of the chart""" 303 if self.options.background.hide: 304 return 305 306 cx.save() 307 308 if self.options.background.baseColor: 309 cx.set_source_rgb(*hex2rgb(self.options.background.baseColor)) 310 cx.paint() 311 312 if self.options.background.chartColor: 313 cx.set_source_rgb(*hex2rgb(self.options.background.chartColor)) 314 surface_width, surface_height = self.getSurfaceSize() 315 cx.rectangle( 316 self.options.padding.left, self.options.padding.top, 317 surface_width - (self.options.padding.left + self.options.padding.right), 318 surface_height - (self.options.padding.top + self.options.padding.bottom) 319 ) 320 cx.fill() 321 322 if self.options.background.lineColor: 323 cx.set_source_rgb(*hex2rgb(self.options.background.lineColor)) 324 cx.set_line_width(self.options.axis.lineWidth) 325 self._renderLines(cx) 326 327 cx.restore() 328 329 def _renderLines(self, cx): 330 """Aux function for _renderBackground""" 331 if self.options.axis.y.showLines and self.yticks: 332 for tick in self.yticks: 333 self._renderLine(cx, tick, False) 334 if self.options.axis.x.showLines and self.xticks: 335 for tick in self.xticks: 336 self._renderLine(cx, tick, True) 337 338 def _renderLine(self, cx, tick, horiz): 339 """Aux function for _renderLines""" 340 x1, x2, y1, y2 = (0, 0, 0, 0) 341 if horiz: 342 x1 = x2 = tick[0] * self.layout.chart.w + self.layout.chart.x 343 y1 = self.layout.chart.y 344 y2 = y1 + self.layout.chart.h 345 else: 346 x1 = self.layout.chart.x 347 x2 = x1 + self.layout.chart.w 348 y1 = y2 = tick[0] * self.layout.chart.h + self.layout.chart.y 349 350 cx.new_path() 351 cx.move_to(x1, y1) 352 cx.line_to(x2, y2) 353 cx.close_path() 354 cx.stroke() 355 356 def _renderChart(self, cx): 357 raise NotImplementedError 358 359 def _renderTick(self, cx, tick, x, y, x2, y2, rotate, text_position): 360 """Aux method for _renderXTick and _renderYTick""" 361 if callable(tick): 362 return 363 364 cx.new_path() 365 cx.move_to(x, y) 366 cx.line_to(x2, y2) 367 cx.close_path() 368 cx.stroke() 369 370 cx.select_font_face(self.options.axis.tickFont, 371 cairo.FONT_SLANT_NORMAL, 372 cairo.FONT_WEIGHT_NORMAL) 373 cx.set_font_size(self.options.axis.tickFontSize) 374 375 label = safe_unicode(tick[1], self.options.encoding) 376 xb, yb, width, height, xa, ya = cx.text_extents(label) 377 378 x, y = text_position 379 380 if rotate: 381 cx.save() 382 cx.translate(x, y) 383 cx.rotate(math.radians(rotate)) 384 x = -width / 2.0 385 y = -height / 2.0 386 cx.move_to(x - xb, y - yb) 387 cx.show_text(label) 388 if self.debug: 389 cx.rectangle(x, y, width, height) 390 cx.stroke() 391 cx.restore() 392 else: 393 x -= width / 2.0 394 y -= height / 2.0 395 cx.move_to(x - xb, y - yb) 396 cx.show_text(label) 397 if self.debug: 398 cx.rectangle(x, y, width, height) 399 cx.stroke() 400 401 return label 402 403 def _renderYTick(self, cx, tick): 404 """Aux method for _renderAxis""" 405 x = self.layout.y_ticks.x + self.layout.y_ticks.w 406 y = self.layout.y_ticks.y + tick[0] * self.layout.y_ticks.h 407 408 text_position = ( 409 (self.layout.y_tick_labels.x + self.layout.y_tick_labels.w / 2.0), 410 y 411 ) 412 413 return self._renderTick(cx, tick, 414 x, y, 415 x - self.options.axis.tickSize, y, 416 self.options.axis.y.rotate, 417 text_position) 418 419 def _renderXTick(self, cx, tick): 420 """Aux method for _renderAxis""" 421 422 x = self.layout.x_ticks.x + tick[0] * self.layout.x_ticks.w 423 y = self.layout.x_ticks.y 424 425 text_position = ( 426 x, 427 (self.layout.x_tick_labels.y + self.layout.x_tick_labels.h / 2.0) 428 ) 429 430 return self._renderTick(cx, tick, 431 x, y, 432 x, y + self.options.axis.tickSize, 433 self.options.axis.x.rotate, 434 text_position) 435 436 def _renderAxisLabel(self, cx, label, x, y, vertical=False): 437 cx.save() 438 cx.select_font_face(self.options.axis.labelFont, 439 cairo.FONT_SLANT_NORMAL, 440 cairo.FONT_WEIGHT_BOLD) 441 cx.set_font_size(self.options.axis.labelFontSize) 442 cx.set_source_rgb(*hex2rgb(self.options.axis.labelColor)) 443 444 xb, yb, width, height, xa, ya = cx.text_extents(label) 445 446 if vertical: 447 y = y + width / 2.0 448 cx.move_to(x - xb, y - yb) 449 cx.translate(x, y) 450 cx.rotate(-math.radians(90)) 451 cx.move_to(-xb, -yb) 452 cx.show_text(label) 453 if self.debug: 454 cx.rectangle(0, 0, width, height) 455 cx.stroke() 456 else: 457 x = x - width / 2.0 458 cx.move_to(x - xb, y - yb) 459 cx.show_text(label) 460 if self.debug: 461 cx.rectangle(x, y, width, height) 462 cx.stroke() 463 cx.restore() 464 465 def _renderYAxisLabel(self, cx, label_text): 466 label = safe_unicode(label_text, self.options.encoding) 467 x = self.layout.y_label.x 468 y = self.layout.y_label.y + self.layout.y_label.h / 2.0 469 self._renderAxisLabel(cx, label, x, y, True) 470 471 def _renderYAxis(self, cx): 472 """Draws the vertical line represeting the Y axis""" 473 cx.new_path() 474 cx.move_to(self.layout.chart.x, self.layout.chart.y) 475 cx.line_to(self.layout.chart.x, 476 self.layout.chart.y + self.layout.chart.h) 477 cx.close_path() 478 cx.stroke() 479 480 def _renderXAxisLabel(self, cx, label_text): 481 label = safe_unicode(label_text, self.options.encoding) 482 x = self.layout.x_label.x + self.layout.x_label.w / 2.0 483 y = self.layout.x_label.y 484 self._renderAxisLabel(cx, label, x, y, False) 485 486 def _renderXAxis(self, cx): 487 """Draws the horizontal line representing the X axis""" 488 cx.new_path() 489 y = self.layout.chart.y + (1.0 - self.origin) * self.layout.chart.h 490 cx.move_to(self.layout.chart.x, y) 491 cx.line_to(self.layout.chart.x + self.layout.chart.w, y) 492 cx.close_path() 493 cx.stroke() 494 495 def _renderAxis(self, cx): 496 """Renders axis""" 497 if self.options.axis.x.hide and self.options.axis.y.hide: 498 return 499 500 cx.save() 501 cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) 502 cx.set_line_width(self.options.axis.lineWidth) 503 504 if not self.options.axis.y.hide: 505 if self.yticks: 506 for tick in self.yticks: 507 self._renderYTick(cx, tick) 508 509 if self.options.axis.y.label: 510 self._renderYAxisLabel(cx, self.options.axis.y.label) 511 512 self._renderYAxis(cx) 513 514 if not self.options.axis.x.hide: 515 if self.xticks: 516 for tick in self.xticks: 517 self._renderXTick(cx, tick) 518 519 if self.options.axis.x.label: 520 self._renderXAxisLabel(cx, self.options.axis.x.label) 521 522 self._renderXAxis(cx) 523 524 cx.restore() 525 526 def _renderTitle(self, cx): 527 if self.options.title: 528 cx.save() 529 cx.select_font_face(self.options.titleFont, 530 cairo.FONT_SLANT_NORMAL, 531 cairo.FONT_WEIGHT_BOLD) 532 cx.set_font_size(self.options.titleFontSize) 533 cx.set_source_rgb(*hex2rgb(self.options.titleColor)) 534 535 title = safe_unicode(self.options.title, self.options.encoding) 536 extents = cx.text_extents(title) 537 title_width = extents[2] 538 539 x = (self.layout.title.x + self.layout.title.w / 2.0 - title_width / 2.0) 540 y = self.layout.title.y - extents[1] 541 542 cx.move_to(x, y) 543 cx.show_text(title) 544 545 cx.restore() 546 547 def _renderLegend(self, cx): 548 """This function adds a legend to the chart""" 549 if self.options.legend.hide: 550 return 551 552 surface_width, surface_height = self.getSurfaceSize() 553 554 # Compute legend dimensions 555 padding = 4 556 bullet = 15 557 width = 0 558 height = padding 559 keys = self._getDatasetsKeys() 560 cx.select_font_face(self.options.legend.legendFont, 561 cairo.FONT_SLANT_NORMAL, 562 cairo.FONT_WEIGHT_NORMAL) 563 cx.set_font_size(self.options.legend.legendFontSize) 564 for key in keys: 565 key = safe_unicode(key, self.options.encoding) 566 extents = cx.text_extents(key) 567 width = max(extents[2], width) 568 height += max(extents[3], bullet) + padding 569 width = padding + bullet + padding + width + padding 570 571 # Compute legend position 572 legend = self.options.legend 573 if legend.position.right is not None: 574 legend.position.left = ( 575 surface_width - legend.position.right - width 576 ) 577 if legend.position.bottom is not None: 578 legend.position.top = ( 579 surface_height - legend.position.bottom - height 580 ) 581 582 # Draw the legend 583 cx.save() 584 cx.rectangle(self.options.legend.position.left, 585 self.options.legend.position.top, 586 width, height) 587 cx.set_source_rgba(1, 1, 1, self.options.legend.opacity) 588 cx.fill_preserve() 589 cx.set_line_width(self.options.legend.borderWidth) 590 cx.set_source_rgb(*hex2rgb(self.options.legend.borderColor)) 591 cx.stroke() 592 593 def drawKey(key, x, y, text_height): 594 cx.rectangle(x, y, bullet, bullet) 595 cx.set_source_rgb(*self.colorScheme[key]) 596 cx.fill_preserve() 597 cx.set_source_rgb(0, 0, 0) 598 cx.stroke() 599 cx.move_to(x + bullet + padding, 600 y + bullet / 2.0 + text_height / 2.0) 601 cx.show_text(key) 602 603 cx.set_line_width(1) 604 x = self.options.legend.position.left + padding 605 y = self.options.legend.position.top + padding 606 for key in keys: 607 extents = cx.text_extents(key) 608 drawKey(key, x, y, extents[3]) 609 y += max(extents[3], bullet) + padding 610 611 cx.restore() 612 613 614def uniqueIndices(arr): 615 """Return a list with the indexes of the biggest element of arr""" 616 return range(max([len(a) for a in arr])) 617 618 619class Area(object): 620 """Simple rectangle to hold an area coordinates and dimensions""" 621 622 def __init__(self, x=0.0, y=0.0, w=0.0, h=0.0): 623 self.x, self.y, self.w, self.h = x, y, w, h 624 625 def __str__(self): 626 msg = "<pycha.chart.Area@(%.2f, %.2f) %.2f x %.2f>" 627 return msg % (self.x, self.y, self.w, self.h) 628 629 630def get_text_extents(cx, text, font, font_size, encoding): 631 if text: 632 cx.save() 633 cx.select_font_face(font, 634 cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) 635 cx.set_font_size(font_size) 636 safe_text = safe_unicode(text, encoding) 637 extents = cx.text_extents(safe_text) 638 cx.restore() 639 return extents[2:4] 640 return (0.0, 0.0) 641 642 643class Layout(object): 644 """Set of chart areas""" 645 646 def __init__(self): 647 self.title = Area() 648 self.x_label = Area() 649 self.y_label = Area() 650 self.x_tick_labels = Area() 651 self.y_tick_labels = Area() 652 self.x_ticks = Area() 653 self.y_ticks = Area() 654 self.chart = Area() 655 656 self._areas = ( 657 (self.title, (1, 126 / 255.0, 0)), # orange 658 (self.y_label, (41 / 255.0, 91 / 255.0, 41 / 255.0)), # grey 659 (self.x_label, (41 / 255.0, 91 / 255.0, 41 / 255.0)), # grey 660 (self.y_tick_labels, (0, 115 / 255.0, 0)), # green 661 (self.x_tick_labels, (0, 115 / 255.0, 0)), # green 662 (self.y_ticks, (229 / 255.0, 241 / 255.0, 18 / 255.0)), # yellow 663 (self.x_ticks, (229 / 255.0, 241 / 255.0, 18 / 255.0)), # yellow 664 (self.chart, (75 / 255.0, 75 / 255.0, 1.0)), # blue 665 ) 666 667 def update(self, cx, options, width, height, xticks, yticks): 668 self.title.x = options.padding.left 669 self.title.y = options.padding.top 670 self.title.w = width - (options.padding.left + options.padding.right) 671 self.title.h = get_text_extents(cx, 672 options.title, 673 options.titleFont, 674 options.titleFontSize, 675 options.encoding)[1] 676 x_axis_label_height = get_text_extents(cx, 677 options.axis.x.label, 678 options.axis.labelFont, 679 options.axis.labelFontSize, 680 options.encoding)[1] 681 y_axis_label_width = get_text_extents(cx, 682 options.axis.y.label, 683 options.axis.labelFont, 684 options.axis.labelFontSize, 685 options.encoding)[1] 686 687 x_axis_tick_labels_height = self._getAxisTickLabelsSize(cx, options, 688 options.axis.x, 689 xticks)[1] 690 y_axis_tick_labels_width = self._getAxisTickLabelsSize(cx, options, 691 options.axis.y, 692 yticks)[0] 693 694 self.y_label.x = options.padding.left 695 self.y_label.y = options.padding.top + self.title.h 696 self.y_label.w = y_axis_label_width 697 self.y_label.h = height - ( 698 options.padding.bottom + options.padding.top + x_axis_label_height + x_axis_tick_labels_height + options.axis.tickSize + self.title.h 699 ) 700 self.x_label.x = ( 701 options.padding.left + y_axis_label_width + y_axis_tick_labels_width + options.axis.tickSize 702 ) 703 self.x_label.y = height - (options.padding.bottom + x_axis_label_height) 704 self.x_label.w = width - ( 705 options.padding.left + options.padding.right + options.axis.tickSize + y_axis_label_width + y_axis_tick_labels_width 706 ) 707 self.x_label.h = x_axis_label_height 708 709 self.y_tick_labels.x = self.y_label.x + self.y_label.w 710 self.y_tick_labels.y = self.y_label.y 711 self.y_tick_labels.w = y_axis_tick_labels_width 712 self.y_tick_labels.h = self.y_label.h 713 714 self.x_tick_labels.x = self.x_label.x 715 self.x_tick_labels.y = self.x_label.y - x_axis_tick_labels_height 716 self.x_tick_labels.w = self.x_label.w 717 self.x_tick_labels.h = x_axis_tick_labels_height 718 719 self.y_ticks.x = self.y_tick_labels.x + self.y_tick_labels.w 720 self.y_ticks.y = self.y_tick_labels.y 721 self.y_ticks.w = options.axis.tickSize 722 self.y_ticks.h = self.y_label.h 723 724 self.x_ticks.x = self.x_tick_labels.x 725 self.x_ticks.y = self.x_tick_labels.y - options.axis.tickSize 726 self.x_ticks.w = self.x_label.w 727 self.x_ticks.h = options.axis.tickSize 728 729 self.chart.x = self.y_ticks.x + self.y_ticks.w 730 self.chart.y = self.title.y + self.title.h 731 self.chart.w = self.x_ticks.w 732 self.chart.h = self.y_ticks.h 733 734 def render(self, cx): 735 736 def draw_area(area, r, g, b): 737 cx.rectangle(area.x, area.y, area.w, area.h) 738 cx.set_source_rgba(r, g, b, 0.5) 739 cx.fill() 740 741 cx.save() 742 for area, color in self._areas: 743 draw_area(area, *color) 744 cx.restore() 745 746 def _getAxisTickLabelsSize(self, cx, options, axis, ticks): 747 cx.save() 748 cx.select_font_face(options.axis.tickFont, 749 cairo.FONT_SLANT_NORMAL, 750 cairo.FONT_WEIGHT_NORMAL) 751 cx.set_font_size(options.axis.tickFontSize) 752 753 max_width = max_height = 0.0 754 if not axis.hide: 755 extents = [ 756 cx.text_extents(safe_unicode(tick[1], options.encoding))[2:4] # get width and height as a tuple 757 for tick in ticks 758 ] 759 if extents: 760 widths, heights = zip(*extents) 761 max_width, max_height = max(widths), max(heights) 762 if axis.rotate: 763 radians = math.radians(axis.rotate) 764 sin = abs(math.sin(radians)) 765 cos = abs(math.cos(radians)) 766 max_width, max_height = ( 767 max_width * cos + max_height * sin, 768 max_width * sin + max_height * cos, 769 ) 770 cx.restore() 771 return max_width, max_height 772 773 774class Option(dict): 775 """Useful dict that allow attribute-like access to its keys""" 776 777 def __getattr__(self, name): 778 if name in self.keys(): 779 return self[name] 780 else: 781 raise AttributeError(name) 782 783 def merge(self, other): 784 """Recursive merge with other Option or dict object""" 785 for key, value in other.items(): 786 if key in self: 787 if isinstance(self[key], Option): 788 self[key].merge(other[key]) 789 else: 790 self[key] = other[key] 791 792 793DEFAULT_OPTIONS = Option( 794 axis=Option( 795 lineWidth=1.0, 796 lineColor='#0f0000', 797 tickSize=3.0, 798 labelColor='#666666', 799 labelFont='Tahoma', 800 labelFontSize=9, 801 tickFont='Tahoma', 802 tickFontSize=9, 803 x=Option( 804 hide=False, 805 ticks=None, 806 tickCount=10, 807 tickPrecision=1, 808 range=None, 809 rotate=None, 810 label=None, 811 interval=0, 812 showLines=False, 813 ), 814 y=Option( 815 hide=False, 816 ticks=None, 817 tickCount=10, 818 tickPrecision=1, 819 range=None, 820 rotate=None, 821 label=None, 822 interval=0, 823 showLines=True, 824 ), 825 ), 826 background=Option( 827 hide=False, 828 baseColor=None, 829 chartColor='#f5f5f5', 830 lineColor='#ffffff', 831 lineWidth=1.5, 832 ), 833 legend=Option( 834 opacity=0.8, 835 borderColor='#000000', 836 borderWidth=2, 837 hide=False, 838 legendFont='Tahoma', 839 legendFontSize=9, 840 position=Option(top=20, left=40, bottom=None, right=None), 841 ), 842 padding=Option( 843 left=10, 844 right=10, 845 top=10, 846 bottom=10, 847 ), 848 stroke=Option( 849 color='#ffffff', 850 hide=False, 851 shadow=True, 852 width=2 853 ), 854 yvals=Option( 855 show=False, 856 inside=False, 857 fontSize=11, 858 fontColor='#000000', 859 skipSmallValues=True, 860 snapToOrigin=False, 861 renderer=None 862 ), 863 fillOpacity=1.0, 864 shouldFill=True, 865 barWidthFillFraction=0.75, 866 pieRadius=0.4, 867 colorScheme=Option( 868 name='gradient', 869 args=Option( 870 initialColor=DEFAULT_COLOR, 871 colors=None, 872 ), 873 ), 874 title=None, 875 titleColor='#000000', 876 titleFont='Tahoma', 877 titleFontSize=12, 878 encoding='utf-8', 879) 880