root/trunk/pycha/chart.py @ 170

Revision 170, 22.2 kB (checked in by lgs, 18 months ago)

Implement different color schemes. Fixes #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 inspect
20import math
21
22import cairo
23
24from pycha.color import ColorScheme, hex2rgb, DEFAULT_COLOR
25
26
27class Chart(object):
28
29    def __init__(self, surface, options={}):
30        # this flag is useful to reuse this chart for drawing different data
31        # or use different options
32        self.resetFlag = False
33
34        # initialize storage
35        self.datasets = []
36
37        # computed values used in several methods
38        self.area = None # chart area without padding or text labels
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        # set the default options
52        self.options = copy.deepcopy(DEFAULT_OPTIONS)
53        if options:
54            self.options.merge(options)
55
56        # initialize the surface
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        # Remove invalid args before calling the constructor
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        # gather data for the x axis
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        # gather data for the y axis
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        # calculate area data
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: # different signs
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        # evaluate xTicks
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        # evaluate yTicks
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) # this is probably faster than a save/restore
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] # font ascent
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        # Compute legend dimensions
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        # Compute legend position
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        # Draw the legend
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
577def 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
582class 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
594class 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
613DEFAULT_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)
Note: See TracBrowser for help on using the browser.