root/trunk/pycha/chart.py @ 169

Revision 169, 21.9 kB (checked in by lgs, 18 months ago)

Big refactor about how the colors scheme are created and used. See #29

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