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