| 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.interval > 0: |
|---|
| 230 | interval = self.options.axis.x.interval |
|---|
| 231 | label = (divmod(self.minxval, interval)[0] + 1) * interval |
|---|
| 232 | pos = self.xscale * (label - self.minxval) |
|---|
| 233 | while 0.0 <= pos <= 1.0: |
|---|
| 234 | self.xticks.append((pos, label)) |
|---|
| 235 | label += interval |
|---|
| 236 | pos = self.xscale * (label - self.minxval) |
|---|
| 237 | |
|---|
| 238 | elif self.options.axis.x.tickCount > 0: |
|---|
| 239 | uniqx = range(len(uniqueIndices(stores)) + 1) |
|---|
| 240 | roughSeparation = self.xrange / self.options.axis.x.tickCount |
|---|
| 241 | i = j = 0 |
|---|
| 242 | while i < len(uniqx) and j < self.options.axis.x.tickCount: |
|---|
| 243 | if (uniqx[i] - self.minxval) >= (j * roughSeparation): |
|---|
| 244 | pos = self.xscale * (uniqx[i] - self.minxval) |
|---|
| 245 | if 0.0 <= pos <= 1.0: |
|---|
| 246 | self.xticks.append((pos, uniqx[i])) |
|---|
| 247 | j += 1 |
|---|
| 248 | i += 1 |
|---|
| 249 | |
|---|
| 250 | |
|---|
| 251 | self.yticks = [] |
|---|
| 252 | if self.options.axis.y.ticks: |
|---|
| 253 | for tick in self.options.axis.y.ticks: |
|---|
| 254 | if not isinstance(tick, Option): |
|---|
| 255 | tick = Option(tick) |
|---|
| 256 | if tick.label is None: |
|---|
| 257 | label = str(tick.v) |
|---|
| 258 | else: |
|---|
| 259 | label = tick.label |
|---|
| 260 | pos = 1.0 - (self.yscale * (tick.v - self.minyval)) |
|---|
| 261 | if 0.0 <= pos <= 1.0: |
|---|
| 262 | self.yticks.append((pos, label)) |
|---|
| 263 | |
|---|
| 264 | elif self.options.axis.y.interval > 0: |
|---|
| 265 | interval = self.options.axis.y.interval |
|---|
| 266 | label = (divmod(self.minyval, interval)[0] + 1) * interval |
|---|
| 267 | pos = 1.0 - (self.yscale * (label - self.minyval)) |
|---|
| 268 | while 0.0 <= pos <= 1.0: |
|---|
| 269 | self.yticks.append((pos, label)) |
|---|
| 270 | label += interval |
|---|
| 271 | pos = 1.0 - (self.yscale * (label - self.minyval)) |
|---|
| 272 | |
|---|
| 273 | elif self.options.axis.y.tickCount > 0: |
|---|
| 274 | prec = self.options.axis.y.tickPrecision |
|---|
| 275 | num = self.yrange / self.options.axis.y.tickCount |
|---|
| 276 | if (num < 1 and prec == 0): |
|---|
| 277 | roughSeparation = 1 |
|---|
| 278 | else: |
|---|
| 279 | roughSeparation = round(num, prec) |
|---|
| 280 | |
|---|
| 281 | for i in range(self.options.axis.y.tickCount + 1): |
|---|
| 282 | yval = self.minyval + (i * roughSeparation) |
|---|
| 283 | pos = 1.0 - ((yval - self.minyval) * self.yscale) |
|---|
| 284 | if 0.0 <= pos <= 1.0: |
|---|
| 285 | self.yticks.append((pos, round(yval, prec))) |
|---|
| 286 | |
|---|
| 287 | def _renderBackground(self, cx): |
|---|
| 288 | """Renders the background area of the chart""" |
|---|
| 289 | if self.options.background.hide: |
|---|
| 290 | return |
|---|
| 291 | |
|---|
| 292 | cx.save() |
|---|
| 293 | |
|---|
| 294 | if self.options.background.baseColor: |
|---|
| 295 | cx.set_source_rgb(*hex2rgb(self.options.background.baseColor)) |
|---|
| 296 | cx.paint() |
|---|
| 297 | |
|---|
| 298 | if self.options.background.chartColor: |
|---|
| 299 | cx.set_source_rgb(*hex2rgb(self.options.background.chartColor)) |
|---|
| 300 | cx.rectangle(self.area.x, self.area.y, self.area.w, self.area.h) |
|---|
| 301 | cx.fill() |
|---|
| 302 | |
|---|
| 303 | if self.options.background.lineColor: |
|---|
| 304 | cx.set_source_rgb(*hex2rgb(self.options.background.lineColor)) |
|---|
| 305 | cx.set_line_width(self.options.axis.lineWidth) |
|---|
| 306 | self._renderLines(cx) |
|---|
| 307 | |
|---|
| 308 | cx.restore() |
|---|
| 309 | |
|---|
| 310 | def _renderLines(self, cx): |
|---|
| 311 | """Aux function for _renderBackground""" |
|---|
| 312 | ticks = self.yticks |
|---|
| 313 | for tick in ticks: |
|---|
| 314 | self._renderLine(cx, tick, False) |
|---|
| 315 | |
|---|
| 316 | def _renderLine(self, cx, tick, horiz): |
|---|
| 317 | """Aux function for _renderLines""" |
|---|
| 318 | x1, x2, y1, y2 = (0, 0, 0, 0) |
|---|
| 319 | if horiz: |
|---|
| 320 | x1 = x2 = tick[0] * self.area.w + self.area.x |
|---|
| 321 | y1 = self.area.y |
|---|
| 322 | y2 = y1 + self.area.h |
|---|
| 323 | else: |
|---|
| 324 | x1 = self.area.x |
|---|
| 325 | x2 = x1 + self.area.w |
|---|
| 326 | y1 = y2 = tick[0] * self.area.h + self.area.y |
|---|
| 327 | |
|---|
| 328 | cx.new_path() |
|---|
| 329 | cx.move_to(x1, y1) |
|---|
| 330 | cx.line_to(x2, y2) |
|---|
| 331 | cx.close_path() |
|---|
| 332 | cx.stroke() |
|---|
| 333 | |
|---|
| 334 | def _renderChart(self, cx): |
|---|
| 335 | raise NotImplementedError |
|---|
| 336 | |
|---|
| 337 | def _renderYTick(self, cx, tick): |
|---|
| 338 | """Aux method for _renderAxis""" |
|---|
| 339 | |
|---|
| 340 | if callable(tick): |
|---|
| 341 | return |
|---|
| 342 | |
|---|
| 343 | x = self.area.x |
|---|
| 344 | y = self.area.y + tick[0] * self.area.h |
|---|
| 345 | |
|---|
| 346 | cx.new_path() |
|---|
| 347 | cx.move_to(x, y) |
|---|
| 348 | cx.line_to(x - self.options.axis.tickSize, y) |
|---|
| 349 | cx.close_path() |
|---|
| 350 | cx.stroke() |
|---|
| 351 | |
|---|
| 352 | cx.select_font_face(self.options.axis.tickFont, |
|---|
| 353 | cairo.FONT_SLANT_NORMAL, |
|---|
| 354 | cairo.FONT_WEIGHT_NORMAL) |
|---|
| 355 | cx.set_font_size(self.options.axis.tickFontSize) |
|---|
| 356 | |
|---|
| 357 | label = unicode(tick[1]) |
|---|
| 358 | extents = cx.text_extents(label) |
|---|
| 359 | labelWidth = extents[2] |
|---|
| 360 | labelHeight = extents[3] |
|---|
| 361 | |
|---|
| 362 | if self.options.axis.y.rotate: |
|---|
| 363 | radians = math.radians(self.options.axis.y.rotate) |
|---|
| 364 | cx.move_to(x - self.options.axis.tickSize |
|---|
| 365 | - (labelWidth * math.cos(radians)) |
|---|
| 366 | - 4, |
|---|
| 367 | y + (labelWidth * math.sin(radians)) |
|---|
| 368 | + labelHeight / (2.0 / math.cos(radians))) |
|---|
| 369 | cx.rotate(-radians) |
|---|
| 370 | cx.show_text(label) |
|---|
| 371 | cx.rotate(radians) |
|---|
| 372 | else: |
|---|
| 373 | cx.move_to(x - self.options.axis.tickSize - labelWidth - 4, |
|---|
| 374 | y + labelHeight / 2.0) |
|---|
| 375 | cx.show_text(label) |
|---|
| 376 | |
|---|
| 377 | return label |
|---|
| 378 | |
|---|
| 379 | def _renderXTick(self, cx, tick, fontAscent): |
|---|
| 380 | if callable(tick): |
|---|
| 381 | return |
|---|
| 382 | |
|---|
| 383 | x = self.area.x + tick[0] * self.area.w |
|---|
| 384 | y = self.area.y + self.area.h |
|---|
| 385 | |
|---|
| 386 | cx.new_path() |
|---|
| 387 | cx.move_to(x, y) |
|---|
| 388 | cx.line_to(x, y + self.options.axis.tickSize) |
|---|
| 389 | cx.close_path() |
|---|
| 390 | cx.stroke() |
|---|
| 391 | |
|---|
| 392 | cx.select_font_face(self.options.axis.tickFont, |
|---|
| 393 | cairo.FONT_SLANT_NORMAL, |
|---|
| 394 | cairo.FONT_WEIGHT_NORMAL) |
|---|
| 395 | cx.set_font_size(self.options.axis.tickFontSize) |
|---|
| 396 | |
|---|
| 397 | label = unicode(tick[1]) |
|---|
| 398 | extents = cx.text_extents(label) |
|---|
| 399 | labelWidth = extents[2] |
|---|
| 400 | labelHeight = extents[3] |
|---|
| 401 | |
|---|
| 402 | if self.options.axis.x.rotate: |
|---|
| 403 | radians = math.radians(self.options.axis.x.rotate) |
|---|
| 404 | cx.move_to(x - (labelHeight * math.cos(radians)), |
|---|
| 405 | y + self.options.axis.tickSize |
|---|
| 406 | + (labelHeight * math.cos(radians)) |
|---|
| 407 | + 4.0) |
|---|
| 408 | cx.rotate(radians) |
|---|
| 409 | cx.show_text(label) |
|---|
| 410 | cx.rotate(-radians) |
|---|
| 411 | else: |
|---|
| 412 | cx.move_to(x - labelWidth / 2.0, |
|---|
| 413 | y + self.options.axis.tickSize |
|---|
| 414 | + fontAscent + 4.0) |
|---|
| 415 | cx.show_text(label) |
|---|
| 416 | return label |
|---|
| 417 | |
|---|
| 418 | def _getTickSize(self, cx, ticks, rotate): |
|---|
| 419 | tickExtents = [cx.text_extents(unicode(tick[1]))[2:4] |
|---|
| 420 | for tick in ticks] |
|---|
| 421 | tickWidth = tickHeight = 0.0 |
|---|
| 422 | if tickExtents: |
|---|
| 423 | tickHeight = self.options.axis.tickSize + 4.0 |
|---|
| 424 | tickWidth = self.options.axis.tickSize + 4.0 |
|---|
| 425 | widths, heights = zip(*tickExtents) |
|---|
| 426 | maxWidth, maxHeight = max(widths), max(heights) |
|---|
| 427 | if rotate: |
|---|
| 428 | radians = math.radians(rotate) |
|---|
| 429 | sinRadians = math.sin(radians) |
|---|
| 430 | cosRadians = math.cos(radians) |
|---|
| 431 | maxHeight = maxWidth * sinRadians + maxHeight * cosRadians |
|---|
| 432 | maxWidth = maxWidth * cosRadians + maxHeight * sinRadians |
|---|
| 433 | tickWidth += maxWidth |
|---|
| 434 | tickHeight += maxHeight |
|---|
| 435 | return tickWidth, tickHeight |
|---|
| 436 | |
|---|
| 437 | def _renderAxisLabel(self, cx, tickWidth, tickHeight, label, x, y, |
|---|
| 438 | vertical=False): |
|---|
| 439 | cx.new_path() |
|---|
| 440 | cx.select_font_face(self.options.axis.labelFont, |
|---|
| 441 | cairo.FONT_SLANT_NORMAL, |
|---|
| 442 | cairo.FONT_WEIGHT_BOLD) |
|---|
| 443 | cx.set_font_size(self.options.axis.labelFontSize) |
|---|
| 444 | labelWidth = cx.text_extents(label)[2] |
|---|
| 445 | fontAscent = cx.font_extents()[0] |
|---|
| 446 | if vertical: |
|---|
| 447 | cx.move_to(x, y + labelWidth / 2) |
|---|
| 448 | radians = math.radians(90) |
|---|
| 449 | cx.rotate(-radians) |
|---|
| 450 | else: |
|---|
| 451 | cx.move_to(x - labelWidth / 2.0, y + fontAscent) |
|---|
| 452 | |
|---|
| 453 | cx.show_text(label) |
|---|
| 454 | |
|---|
| 455 | def _renderYAxis(self, cx): |
|---|
| 456 | """Draws the vertical line represeting the Y axis""" |
|---|
| 457 | cx.new_path() |
|---|
| 458 | cx.move_to(self.area.x, self.area.y) |
|---|
| 459 | cx.line_to(self.area.x, self.area.y + self.area.h) |
|---|
| 460 | cx.close_path() |
|---|
| 461 | cx.stroke() |
|---|
| 462 | |
|---|
| 463 | def _renderXAxis(self, cx): |
|---|
| 464 | """Draws the horizontal line representing the X axis""" |
|---|
| 465 | cx.new_path() |
|---|
| 466 | cx.move_to(self.area.x, |
|---|
| 467 | self.area.y + self.area.h * (1.0 - self.area.origin)) |
|---|
| 468 | cx.line_to(self.area.x + self.area.w, |
|---|
| 469 | self.area.y + self.area.h * (1.0 - self.area.origin)) |
|---|
| 470 | cx.close_path() |
|---|
| 471 | cx.stroke() |
|---|
| 472 | |
|---|
| 473 | def _renderAxis(self, cx): |
|---|
| 474 | """Renders axis""" |
|---|
| 475 | if self.options.axis.x.hide and self.options.axis.y.hide: |
|---|
| 476 | return |
|---|
| 477 | |
|---|
| 478 | cx.save() |
|---|
| 479 | cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) |
|---|
| 480 | cx.set_line_width(self.options.axis.lineWidth) |
|---|
| 481 | |
|---|
| 482 | if not self.options.axis.y.hide: |
|---|
| 483 | if self.yticks: |
|---|
| 484 | for tick in self.yticks: |
|---|
| 485 | self._renderYTick(cx, tick) |
|---|
| 486 | |
|---|
| 487 | if self.options.axis.y.label: |
|---|
| 488 | cx.save() |
|---|
| 489 | rotate = self.options.axis.y.rotate |
|---|
| 490 | tickWidth, tickHeight = self._getTickSize(cx, self.yticks, |
|---|
| 491 | rotate) |
|---|
| 492 | label = unicode(self.options.axis.y.label) |
|---|
| 493 | x = self.area.x - tickWidth - 4.0 |
|---|
| 494 | y = self.area.y + 0.5 * self.area.h |
|---|
| 495 | self._renderAxisLabel(cx, tickWidth, tickHeight, label, x, y, |
|---|
| 496 | True) |
|---|
| 497 | cx.restore() |
|---|
| 498 | |
|---|
| 499 | self._renderYAxis(cx) |
|---|
| 500 | |
|---|
| 501 | if not self.options.axis.x.hide: |
|---|
| 502 | fontAscent = cx.font_extents()[0] |
|---|
| 503 | if self.xticks: |
|---|
| 504 | for tick in self.xticks: |
|---|
| 505 | self._renderXTick(cx, tick, fontAscent) |
|---|
| 506 | |
|---|
| 507 | if self.options.axis.x.label: |
|---|
| 508 | cx.save() |
|---|
| 509 | rotate = self.options.axis.x.rotate |
|---|
| 510 | tickWidth, tickHeight = self._getTickSize(cx, self.xticks, |
|---|
| 511 | rotate) |
|---|
| 512 | label = unicode(self.options.axis.x.label) |
|---|
| 513 | x = self.area.x + self.area.w / 2.0 |
|---|
| 514 | y = self.area.y + self.area.h + tickHeight + 4.0 |
|---|
| 515 | self._renderAxisLabel(cx, tickWidth, tickHeight, label, x, y, |
|---|
| 516 | False) |
|---|
| 517 | cx.restore() |
|---|
| 518 | |
|---|
| 519 | self._renderXAxis(cx) |
|---|
| 520 | |
|---|
| 521 | cx.restore() |
|---|
| 522 | |
|---|
| 523 | def _renderTitle(self, cx): |
|---|
| 524 | if self.options.title: |
|---|
| 525 | cx.save() |
|---|
| 526 | cx.select_font_face(self.options.titleFont, |
|---|
| 527 | cairo.FONT_SLANT_NORMAL, |
|---|
| 528 | cairo.FONT_WEIGHT_BOLD) |
|---|
| 529 | cx.set_font_size(self.options.titleFontSize) |
|---|
| 530 | |
|---|
| 531 | title = unicode(self.options.title) |
|---|
| 532 | extents = cx.text_extents(title) |
|---|
| 533 | titleWidth = extents[2] |
|---|
| 534 | |
|---|
| 535 | x = self.area.x + self.area.w / 2.0 - titleWidth / 2.0 |
|---|
| 536 | y = cx.font_extents()[0] |
|---|
| 537 | |
|---|
| 538 | cx.move_to(x, y) |
|---|
| 539 | cx.show_text(title) |
|---|
| 540 | |
|---|
| 541 | cx.restore() |
|---|
| 542 | |
|---|
| 543 | def _renderLegend(self, cx): |
|---|
| 544 | """This function adds a legend to the chart""" |
|---|
| 545 | if self.options.legend.hide: |
|---|
| 546 | return |
|---|
| 547 | |
|---|
| 548 | surface_width, surface_height = self.getSurfaceSize() |
|---|
| 549 | |
|---|
| 550 | |
|---|
| 551 | padding = 4 |
|---|
| 552 | bullet = 15 |
|---|
| 553 | width = 0 |
|---|
| 554 | height = padding |
|---|
| 555 | keys = self._getDatasetsKeys() |
|---|
| 556 | for key in keys: |
|---|
| 557 | extents = cx.text_extents(key) |
|---|
| 558 | width = max(extents[2], width) |
|---|
| 559 | height += max(extents[3], bullet) + padding |
|---|
| 560 | width = padding + bullet + padding + width + padding |
|---|
| 561 | |
|---|
| 562 | |
|---|
| 563 | legend = self.options.legend |
|---|
| 564 | if legend.position.right is not None: |
|---|
| 565 | legend.position.left = (surface_width |
|---|
| 566 | - legend.position.right |
|---|
| 567 | - width) |
|---|
| 568 | if legend.position.bottom is not None: |
|---|
| 569 | legend.position.top = (surface_height |
|---|
| 570 | - legend.position.bottom |
|---|
| 571 | - height) |
|---|
| 572 | |
|---|
| 573 | |
|---|
| 574 | cx.save() |
|---|
| 575 | cx.rectangle(self.options.legend.position.left, |
|---|
| 576 | self.options.legend.position.top, |
|---|
| 577 | width, height) |
|---|
| 578 | cx.set_source_rgba(1, 1, 1, self.options.legend.opacity) |
|---|
| 579 | cx.fill_preserve() |
|---|
| 580 | cx.set_line_width(self.options.stroke.width) |
|---|
| 581 | cx.set_source_rgb(*hex2rgb(self.options.legend.borderColor)) |
|---|
| 582 | cx.stroke() |
|---|
| 583 | |
|---|
| 584 | def drawKey(key, x, y, text_height): |
|---|
| 585 | cx.rectangle(x, y, bullet, bullet) |
|---|
| 586 | cx.set_source_rgb(*self.colorScheme[key]) |
|---|
| 587 | cx.fill_preserve() |
|---|
| 588 | cx.set_source_rgb(0, 0, 0) |
|---|
| 589 | cx.stroke() |
|---|
| 590 | cx.move_to(x + bullet + padding, |
|---|
| 591 | y + bullet / 2.0 + text_height / 2.0) |
|---|
| 592 | cx.show_text(key) |
|---|
| 593 | |
|---|
| 594 | cx.set_line_width(1) |
|---|
| 595 | x = self.options.legend.position.left + padding |
|---|
| 596 | y = self.options.legend.position.top + padding |
|---|
| 597 | for key in keys: |
|---|
| 598 | extents = cx.text_extents(key) |
|---|
| 599 | drawKey(key, x, y, extents[3]) |
|---|
| 600 | y += max(extents[3], bullet) + padding |
|---|
| 601 | |
|---|
| 602 | cx.restore() |
|---|
| 603 | |
|---|
| 604 | |
|---|
| 605 | def uniqueIndices(arr): |
|---|
| 606 | """Return a list with the indexes of the biggest element of arr""" |
|---|
| 607 | return range(max([len(a) for a in arr])) |
|---|
| 608 | |
|---|
| 609 | |
|---|
| 610 | class Area(object): |
|---|
| 611 | """Simple rectangle to hold an area coordinates and dimensions""" |
|---|
| 612 | |
|---|
| 613 | def __init__(self, x, y, w, h, origin=0.0): |
|---|
| 614 | self.x, self.y, self.w, self.h = x, y, w, h |
|---|
| 615 | self.origin = origin |
|---|
| 616 | |
|---|
| 617 | def __str__(self): |
|---|
| 618 | msg = "<pycha.chart.Area@(%.2f, %.2f) %.2f x %.2f Origin: %.2f>" |
|---|
| 619 | return msg % (self.x, self.y, self.w, self.h, self.origin) |
|---|
| 620 | |
|---|
| 621 | |
|---|
| 622 | class Option(dict): |
|---|
| 623 | """Useful dict that allow attribute-like access to its keys""" |
|---|
| 624 | |
|---|
| 625 | def __getattr__(self, name): |
|---|
| 626 | if name in self.keys(): |
|---|
| 627 | return self[name] |
|---|
| 628 | else: |
|---|
| 629 | raise AttributeError(name) |
|---|
| 630 | |
|---|
| 631 | def merge(self, other): |
|---|
| 632 | """Recursive merge with other Option or dict object""" |
|---|
| 633 | for key, value in other.items(): |
|---|
| 634 | if key in self: |
|---|
| 635 | if isinstance(self[key], Option): |
|---|
| 636 | self[key].merge(other[key]) |
|---|
| 637 | else: |
|---|
| 638 | self[key] = other[key] |
|---|
| 639 | |
|---|
| 640 | |
|---|
| 641 | DEFAULT_OPTIONS = Option( |
|---|
| 642 | axis=Option( |
|---|
| 643 | lineWidth=1.0, |
|---|
| 644 | lineColor='#0f0000', |
|---|
| 645 | tickSize=3.0, |
|---|
| 646 | labelColor='#666666', |
|---|
| 647 | labelFont='Tahoma', |
|---|
| 648 | labelFontSize=9, |
|---|
| 649 | labelWidth=50.0, |
|---|
| 650 | tickFont='Tahoma', |
|---|
| 651 | tickFontSize=9, |
|---|
| 652 | x=Option( |
|---|
| 653 | hide=False, |
|---|
| 654 | ticks=None, |
|---|
| 655 | tickCount=10, |
|---|
| 656 | tickPrecision=1, |
|---|
| 657 | range=None, |
|---|
| 658 | rotate=None, |
|---|
| 659 | label=None, |
|---|
| 660 | interval=0, |
|---|
| 661 | ), |
|---|
| 662 | y=Option( |
|---|
| 663 | hide=False, |
|---|
| 664 | ticks=None, |
|---|
| 665 | tickCount=10, |
|---|
| 666 | tickPrecision=1, |
|---|
| 667 | range=None, |
|---|
| 668 | rotate=None, |
|---|
| 669 | label=None, |
|---|
| 670 | interval=0, |
|---|
| 671 | ), |
|---|
| 672 | ), |
|---|
| 673 | background=Option( |
|---|
| 674 | hide=False, |
|---|
| 675 | baseColor=None, |
|---|
| 676 | chartColor='#f5f5f5', |
|---|
| 677 | lineColor='#ffffff', |
|---|
| 678 | lineWidth=1.5, |
|---|
| 679 | ), |
|---|
| 680 | legend=Option( |
|---|
| 681 | opacity=0.8, |
|---|
| 682 | borderColor='#000000', |
|---|
| 683 | hide=False, |
|---|
| 684 | position=Option(top=20, left=40, bottom=None, right=None), |
|---|
| 685 | ), |
|---|
| 686 | padding=Option( |
|---|
| 687 | left=30, |
|---|
| 688 | right=30, |
|---|
| 689 | top=30, |
|---|
| 690 | bottom=30, |
|---|
| 691 | ), |
|---|
| 692 | stroke=Option( |
|---|
| 693 | color='#ffffff', |
|---|
| 694 | hide=False, |
|---|
| 695 | shadow=True, |
|---|
| 696 | width=2 |
|---|
| 697 | ), |
|---|
| 698 | yvals=Option( |
|---|
| 699 | show=False, |
|---|
| 700 | inside=False, |
|---|
| 701 | fontSize=11, |
|---|
| 702 | fontColor='#000000', |
|---|
| 703 | ), |
|---|
| 704 | fillOpacity=1.0, |
|---|
| 705 | shouldFill=True, |
|---|
| 706 | barWidthFillFraction=0.75, |
|---|
| 707 | pieRadius=0.4, |
|---|
| 708 | colorScheme=Option( |
|---|
| 709 | name='gradient', |
|---|
| 710 | args=Option( |
|---|
| 711 | initialColor=DEFAULT_COLOR, |
|---|
| 712 | colors=None, |
|---|
| 713 | ), |
|---|
| 714 | ), |
|---|
| 715 | title=None, |
|---|
| 716 | titleFont='Tahoma', |
|---|
| 717 | titleFontSize=12, |
|---|
| 718 | ) |
|---|