root/trunk/pycha/chart.py @ 184

Revision 175, 23.5 kB (checked in by lgs, 18 months ago)

Allow to specify the font of the ticks. Based on a patch by ged_AT_openhex.org. Fixes #26

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.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        # evaluate yTicks
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) # this is probably faster than a save/restore
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] # font ascent
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        # Compute legend dimensions
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        # Compute legend position
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        # Draw the legend
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
605def 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
610class 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
622class 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
641DEFAULT_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)
Note: See TracBrowser for help on using the browser.