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 math
19
20import cairocffi as cairo
21
22from pycha.chart import Chart, Option, Layout, Area, get_text_extents
23from pycha.color import hex2rgb
24
25
26class PieChart(Chart):
27
28    def __init__(self, surface=None, options={}, debug=False):
29        super(PieChart, self).__init__(surface, options, debug)
30        self.slices = []
31        self.centerx = 0
32        self.centery = 0
33        self.layout = PieLayout(self.slices)
34
35    def _updateChart(self):
36        """Evaluates measures for pie charts"""
37        slices = [dict(name=key,
38                       value=(i, value[0][1]))
39                  for i, (key, value) in enumerate(self.datasets)]
40
41        s = float(sum([slice['value'][1] for slice in slices]))
42
43        fraction = angle = 0.0
44
45        del self.slices[:]
46        for slice in slices:
47            if slice['value'][1] > 0:
48                angle += fraction
49                fraction = slice['value'][1] / s
50                self.slices.append(Slice(slice['name'], fraction,
51                                         slice['value'][0], slice['value'][1],
52                                         angle))
53
54    def _updateTicks(self):
55        """Evaluates pie ticks"""
56        self.xticks = []
57        if self.options.axis.x.ticks:
58            lookup = dict([(slice.xval, slice) for slice in self.slices])
59            for tick in self.options.axis.x.ticks:
60                if not isinstance(tick, Option):
61                    tick = Option(tick)
62                slice = lookup.get(tick.v, None)
63                label = tick.label or str(tick.v)
64                if slice is not None:
65                    label += ' (%.1f%%)' % (slice.fraction * 100)
66                    self.xticks.append((tick.v, label))
67        else:
68            for slice in self.slices:
69                label = '%s (%.1f%%)' % (slice.name, slice.fraction * 100)
70                self.xticks.append((slice.xval, label))
71
72    def _renderLines(self, cx):
73        """Aux function for _renderBackground"""
74        # there are no lines in a Pie Chart
75
76    def _renderChart(self, cx):
77        """Renders a pie chart"""
78        self.centerx = self.layout.chart.x + self.layout.chart.w * 0.5
79        self.centery = self.layout.chart.y + self.layout.chart.h * 0.5
80
81        cx.set_line_join(cairo.LINE_JOIN_ROUND)
82
83        if self.options.stroke.shadow and False:
84            cx.save()
85            cx.set_source_rgba(0, 0, 0, 0.15)
86
87            cx.new_path()
88            cx.move_to(self.centerx, self.centery)
89            cx.arc(self.centerx + 1, self.centery + 2,
90                   self.layout.radius + 1, 0, math.pi * 2)
91            cx.line_to(self.centerx, self.centery)
92            cx.close_path()
93            cx.fill()
94            cx.restore()
95
96        cx.save()
97        for slice in self.slices:
98            if slice.isBigEnough():
99                cx.set_source_rgb(*self.colorScheme[slice.name])
100                if self.options.shouldFill:
101                    slice.draw(cx, self.centerx, self.centery,
102                               self.layout.radius)
103                    cx.fill()
104
105                if not self.options.stroke.hide:
106                    slice.draw(cx, self.centerx, self.centery,
107                               self.layout.radius)
108                    cx.set_line_width(self.options.stroke.width)
109                    cx.set_source_rgb(*hex2rgb(self.options.stroke.color))
110                    cx.stroke()
111
112        cx.restore()
113
114        if self.debug:
115            cx.set_source_rgba(1, 0, 0, 0.5)
116            px = max(cx.device_to_user_distance(1, 1))
117            for x, y in self.layout._lines:
118                cx.arc(x, y, 5 * px, 0, 2 * math.pi)
119                cx.fill()
120                cx.new_path()
121                cx.move_to(self.centerx, self.centery)
122                cx.line_to(x, y)
123                cx.stroke()
124
125    def _renderAxis(self, cx):
126        """Renders the axis for pie charts"""
127        if self.options.axis.x.hide or not self.xticks:
128            return
129
130        self.xlabels = []
131
132        if self.debug:
133            px = max(cx.device_to_user_distance(1, 1))
134            cx.set_source_rgba(0, 0, 1, 0.5)
135            for x, y, w, h in self.layout.ticks:
136                cx.rectangle(x, y, w, h)
137                cx.stroke()
138                cx.arc(x + w / 2.0, y + h / 2.0, 5 * px, 0, 2 * math.pi)
139                cx.fill()
140                cx.arc(x, y, 2 * px, 0, 2 * math.pi)
141                cx.fill()
142
143        cx.select_font_face(self.options.axis.tickFont,
144                            cairo.FONT_SLANT_NORMAL,
145                            cairo.FONT_WEIGHT_NORMAL)
146        cx.set_font_size(self.options.axis.tickFontSize)
147
148        cx.set_source_rgb(*hex2rgb(self.options.axis.labelColor))
149
150        for i, tick in enumerate(self.xticks):
151            label = tick[1]
152            x, y, w, h = self.layout.ticks[i]
153
154            xb, yb, width, height, xa, ya = cx.text_extents(label)
155
156            # draw label with text tick[1]
157            cx.move_to(x - xb, y - yb)
158            cx.show_text(label)
159            self.xlabels.append(label)
160
161
162class Slice(object):
163
164    def __init__(self, name, fraction, xval, yval, angle):
165        self.name = name
166        self.fraction = fraction
167        self.xval = xval
168        self.yval = yval
169        self.startAngle = 2 * angle * math.pi
170        self.endAngle = 2 * (angle + fraction) * math.pi
171
172    def __str__(self):
173        return ("<pycha.pie.Slice from %.2f to %.2f (%.2f%%)>" %
174                (self.startAngle, self.endAngle, self.fraction))
175
176    def isBigEnough(self):
177        return abs(self.startAngle - self.endAngle) > 0.001
178
179    def draw(self, cx, centerx, centery, radius):
180        cx.new_path()
181        cx.move_to(centerx, centery)
182        cx.arc(centerx, centery, radius, -self.endAngle, -self.startAngle)
183        cx.close_path()
184
185    def getNormalisedAngle(self):
186        normalisedAngle = (self.startAngle + self.endAngle) / 2
187
188        if normalisedAngle > math.pi * 2:
189            normalisedAngle -= math.pi * 2
190        elif normalisedAngle < 0:
191            normalisedAngle += math.pi * 2
192
193        return normalisedAngle
194
195
196class PieLayout(Layout):
197    """Set of chart areas for pie charts"""
198
199    def __init__(self, slices):
200        self.slices = slices
201
202        self.title = Area()
203        self.chart = Area()
204
205        self.ticks = []
206        self.radius = 0
207
208        self._areas = (
209            (self.title, (1, 126 / 255.0, 0)),  # orange
210            (self.chart, (75 / 255.0, 75 / 255.0, 1.0)),  # blue
211        )
212
213        self._lines = []
214
215    def update(self, cx, options, width, height, xticks, yticks):
216        self.title.x = options.padding.left
217        self.title.y = options.padding.top
218        self.title.w = width - (options.padding.left + options.padding.right)
219        self.title.h = get_text_extents(cx,
220                                        options.title,
221                                        options.titleFont,
222                                        options.titleFontSize,
223                                        options.encoding)[1]
224
225        lookup = dict([(slice.xval, slice) for slice in self.slices])
226
227        self.chart.x = self.title.x
228        self.chart.y = self.title.y + self.title.h
229        self.chart.w = self.title.w
230        self.chart.h = height - self.title.h - (
231            options.padding.top + options.padding.bottom
232        )
233
234        centerx = self.chart.x + self.chart.w * 0.5
235        centery = self.chart.y + self.chart.h * 0.5
236
237        self.radius = min(self.chart.w / 2.0, self.chart.h / 2.0)
238        for tick in xticks:
239            slice = lookup.get(tick[0], None)
240            width, height = get_text_extents(cx, tick[1],
241                                             options.axis.tickFont,
242                                             options.axis.tickFontSize,
243                                             options.encoding)
244            angle = slice.getNormalisedAngle()
245            radius = self._get_min_radius(angle, centerx, centery,
246                                          width, height)
247            self.radius = min(self.radius, radius)
248
249        # Now that we now the radius we move the ticks as close as we can
250        # to the circle
251        for i, tick in enumerate(xticks):
252            slice = lookup.get(tick[0], None)
253            angle = slice.getNormalisedAngle()
254            self.ticks[i] = self._get_tick_position(self.radius, angle,
255                                                    self.ticks[i],
256                                                    centerx, centery)
257
258    def _get_min_radius(self, angle, centerx, centery, width, height):
259        min_radius = None
260
261        # precompute some common values
262        tan = math.tan(angle)
263        half_width = width / 2.0
264        half_height = height / 2.0
265        offset_x = half_width * tan
266        offset_y = half_height / tan
267
268        def intersect_horizontal_line(y):
269            return centerx + (centery - y) / tan
270
271        def intersect_vertical_line(x):
272            return centery - tan * (x - centerx)
273
274        # computes the intersection between the rect that has
275        # that angle with the X axis and the bounding chart box
276        if 0.25 * math.pi <= angle < 0.75 * math.pi:
277            # intersects with the top rect
278            y = self.chart.y
279            x = intersect_horizontal_line(y)
280            self._lines.append((x, y))
281
282            x1 = x - half_width - offset_y
283            self.ticks.append((x1, self.chart.y, width, height))
284
285            min_radius = abs((y + height) - centery)
286        elif 0.75 * math.pi <= angle < 1.25 * math.pi:
287            # intersects with the left rect
288            x = self.chart.x
289            y = intersect_vertical_line(x)
290            self._lines.append((x, y))
291
292            y1 = y - half_height - offset_x
293            self.ticks.append((x, y1, width, height))
294
295            min_radius = abs(centerx - (x + width))
296        elif 1.25 * math.pi <= angle < 1.75 * math.pi:
297            # intersects with the bottom rect
298            y = self.chart.y + self.chart.h
299            x = intersect_horizontal_line(y)
300            self._lines.append((x, y))
301
302            x1 = x - half_width + offset_y
303            self.ticks.append((x1, y - height, width, height))
304
305            min_radius = abs((y - height) - centery)
306        else:
307            # intersects with the right rect
308            x = self.chart.x + self.chart.w
309            y = intersect_vertical_line(x)
310            self._lines.append((x, y))
311
312            y1 = y - half_height + offset_x
313            self.ticks.append((x - width, y1, width, height))
314
315            min_radius = abs((x - width) - centerx)
316
317        return min_radius
318
319    def _get_tick_position(self, radius, angle, tick, centerx, centery):
320        text_width, text_height = tick[2:4]
321        half_width = text_width / 2.0
322        half_height = text_height / 2.0
323
324        if 0 <= angle < 0.5 * math.pi:
325            # first quadrant
326            k1 = j1 = k2 = 1
327            j2 = -1
328        elif 0.5 * math.pi <= angle < math.pi:
329            # second quadrant
330            k1 = k2 = -1
331            j1 = j2 = 1
332        elif math.pi <= angle < 1.5 * math.pi:
333            # third quadrant
334            k1 = j1 = k2 = -1
335            j2 = 1
336        elif 1.5 * math.pi <= angle < 2 * math.pi:
337            # fourth quadrant
338            k1 = k2 = 1
339            j1 = j2 = -1
340
341        cx = radius * math.cos(angle) + k1 * half_width
342        cy = radius * math.sin(angle) + j1 * half_height
343
344        radius2 = math.sqrt(cx * cx + cy * cy)
345
346        tan = math.tan(angle)
347        x = math.sqrt((radius2 * radius2) / (1 + tan * tan))
348        y = tan * x
349
350        x = centerx + k2 * x
351        y = centery + j2 * y
352
353        return x - half_width, y - half_height, text_width, text_height
354