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
18from pycha.chart import Chart, uniqueIndices
19from pycha.color import hex2rgb
20from pycha.utils import safe_unicode
21
22
23class BarChart(Chart):
24
25    def __init__(self, surface=None, options={}, debug=False):
26        super(BarChart, self).__init__(surface, options, debug)
27        self.bars = []
28        self.minxdelta = 0.0
29        self.barWidthForSet = 0.0
30        self.barMargin = 0.0
31
32    def _updateXY(self):
33        super(BarChart, self)._updateXY()
34        # each dataset is centered around a line segment. that's why we
35        # need n + 1 divisions on the x axis
36        self.xscale = 1 / (self.xrange + 1.0)
37
38    def _updateChart(self):
39        """Evaluates measures for vertical bars"""
40        stores = self._getDatasetsValues()
41        uniqx = uniqueIndices(stores)
42
43        if len(uniqx) == 1:
44            self.minxdelta = 1.0
45        else:
46            self.minxdelta = min([abs(uniqx[j] - uniqx[j - 1])
47                                  for j in range(1, len(uniqx))])
48
49        k = self.minxdelta * self.xscale
50        barWidth = k * self.options.barWidthFillFraction
51        self.barWidthForSet = barWidth / len(stores)
52        self.barMargin = k * (1.0 - self.options.barWidthFillFraction) / 2
53
54        self.bars = []
55
56    def _renderChart(self, cx):
57        """Renders a horizontal/vertical bar chart"""
58
59        def drawBar(bar):
60            stroke_width = self.options.stroke.width
61            ux, uy = cx.device_to_user_distance(stroke_width, stroke_width)
62            if ux < uy:
63                ux = uy
64            cx.set_line_width(ux)
65
66            # gather bar proportions
67            x = self.layout.chart.x + self.layout.chart.w * bar.x
68            y = self.layout.chart.y + self.layout.chart.h * bar.y
69            w = self.layout.chart.w * bar.w
70            h = self.layout.chart.h * bar.h
71
72            if (w < 1 or h < 1) and self.options.yvals.skipSmallValues:
73                return  # don't draw when the bar is too small
74
75            if self.options.stroke.shadow:
76                cx.set_source_rgba(0, 0, 0, 0.15)
77                rectangle = self._getShadowRectangle(x, y, w, h)
78                cx.rectangle(*rectangle)
79                cx.fill()
80
81            if self.options.shouldFill or (not self.options.stroke.hide):
82
83                if self.options.shouldFill:
84                    cx.set_source_rgb(*self.colorScheme[bar.name])
85                    cx.rectangle(x, y, w, h)
86                    cx.fill()
87
88                if not self.options.stroke.hide:
89                    cx.set_source_rgb(*hex2rgb(self.options.stroke.color))
90                    cx.rectangle(x, y, w, h)
91                    cx.stroke()
92
93            if bar.yerr:
94                self._renderError(cx, x, y, w, h, bar.yval, bar.yerr)
95
96            # render yvals above/beside bars
97            if self.options.yvals.show:
98                cx.save()
99                cx.set_font_size(self.options.yvals.fontSize)
100                cx.set_source_rgb(*hex2rgb(self.options.yvals.fontColor))
101
102                if callable(self.options.yvals.renderer):
103                    label = safe_unicode(self.options.yvals.renderer(bar),
104                                         self.options.encoding)
105                else:
106                    label = safe_unicode(bar.yval, self.options.encoding)
107                extents = cx.text_extents(label)
108                labelW = extents[2]
109                labelH = extents[3]
110
111                self._renderYVal(cx, label, labelW, labelH, x, y, w, h)
112
113                cx.restore()
114
115        cx.save()
116        for bar in self.bars:
117            drawBar(bar)
118        cx.restore()
119
120    def _renderYVal(self, cx, label, width, height, x, y, w, h):
121        raise NotImplementedError
122
123
124class VerticalBarChart(BarChart):
125
126    def _updateChart(self):
127        """Evaluates measures for vertical bars"""
128        super(VerticalBarChart, self)._updateChart()
129        for i, (name, store) in enumerate(self.datasets):
130            for item in store:
131                if len(item) == 3:
132                    xval, yval, _ = item
133                else:
134                    xval, yval = item
135
136                x = (((xval - self.minxval) * self.xscale) + self.barMargin + (i * self.barWidthForSet))
137                w = self.barWidthForSet
138                h = abs(yval) * self.yscale
139                if yval > 0:
140                    y = (1.0 - h) - self.origin
141                else:
142                    y = 1 - self.origin
143                rect = Rect(x, y, w, h, xval, yval, name)
144
145                if (0.0 <= rect.x <= 1.0) and (0.0 <= rect.y <= 1.0):
146                    self.bars.append(rect)
147
148    def _updateTicks(self):
149        """Evaluates bar ticks"""
150        super(BarChart, self)._updateTicks()
151        offset = (self.minxdelta * self.xscale) / 2
152        self.xticks = [(tick[0] + offset, tick[1]) for tick in self.xticks]
153
154    def _getShadowRectangle(self, x, y, w, h):
155        return (x - 2, y - 2, w + 4, h + 2)
156
157    def _renderYVal(self, cx, label, labelW, labelH, barX, barY, barW, barH):
158        x = barX + (barW / 2.0) - (labelW / 2.0)
159        if self.options.yvals.snapToOrigin:
160            y = barY + barH - 0.5 * labelH
161        elif self.options.yvals.inside:
162            y = barY + (1.5 * labelH)
163        else:
164            y = barY - 0.5 * labelH
165
166        # if the label doesn't fit below the bar, put it above the bar
167        if y > (barY + barH):
168            y = barY - 0.5 * labelH
169
170        cx.move_to(x, y)
171        cx.show_text(label)
172
173    def _renderError(self, cx, barX, barY, barW, barH, value, error):
174        center = barX + (barW / 2.0)
175        errorWidth = max(barW * 0.1, 5.0)
176        left = center - errorWidth
177        right = center + errorWidth
178        errorSize = barH * error / value
179        top = barY + errorSize
180        bottom = barY - errorSize
181
182        cx.set_source_rgb(0, 0, 0)
183        cx.move_to(left, top)
184        cx.line_to(right, top)
185        cx.stroke()
186        cx.move_to(center, top)
187        cx.line_to(center, bottom)
188        cx.stroke()
189        cx.move_to(left, bottom)
190        cx.line_to(right, bottom)
191        cx.stroke()
192
193
194class HorizontalBarChart(BarChart):
195
196    def _updateChart(self):
197        """Evaluates measures for horizontal bars"""
198        super(HorizontalBarChart, self)._updateChart()
199
200        for i, (name, store) in enumerate(self.datasets):
201            for item in store:
202                if len(item) == 3:
203                    xval, yval, yerr = item
204                else:
205                    xval, yval = item
206                    yerr = 0.0
207
208                y = (((xval - self.minxval) * self.xscale) + self.barMargin + (i * self.barWidthForSet))
209                h = self.barWidthForSet
210                w = abs(yval) * self.yscale
211                if yval > 0:
212                    x = self.origin
213                else:
214                    x = self.origin - w
215                rect = Rect(x, y, w, h, xval, yval, name, yerr)
216
217                if (0.0 <= rect.x <= 1.0) and (0.0 <= rect.y <= 1.0):
218                    self.bars.append(rect)
219
220    def _updateTicks(self):
221        """Evaluates bar ticks"""
222        super(BarChart, self)._updateTicks()
223        offset = (self.minxdelta * self.xscale) / 2
224        tmp = self.xticks
225        self.xticks = [(1.0 - tick[0], tick[1]) for tick in self.yticks]
226        self.yticks = [(tick[0] + offset, tick[1]) for tick in tmp]
227
228    def _renderLines(self, cx):
229        """Aux function for _renderBackground"""
230        if self.options.axis.y.showLines and self.yticks:
231            for tick in self.xticks:
232                self._renderLine(cx, tick, True)
233        if self.options.axis.x.showLines and self.xticks:
234            for tick in self.yticks:
235                self._renderLine(cx, tick, False)
236
237    def _getShadowRectangle(self, x, y, w, h):
238        return (x, y - 2, w + 2, h + 4)
239
240    def _renderXAxisLabel(self, cx, labelText):
241        labelText = self.options.axis.x.label
242        super(HorizontalBarChart, self)._renderYAxisLabel(cx, labelText)
243
244    def _renderXAxis(self, cx):
245        """Draws the horizontal line representing the X axis"""
246        cx.new_path()
247        cx.move_to(self.layout.chart.x,
248                   self.layout.chart.y + self.layout.chart.h)
249        cx.line_to(self.layout.chart.x + self.layout.chart.w,
250                   self.layout.chart.y + self.layout.chart.h)
251        cx.close_path()
252        cx.stroke()
253
254    def _renderYAxisLabel(self, cx, labelText):
255        labelText = self.options.axis.y.label
256        super(HorizontalBarChart, self)._renderXAxisLabel(cx, labelText)
257
258    def _renderYAxis(self, cx):
259        # draws the vertical line representing the Y axis
260        cx.new_path()
261        cx.move_to(self.layout.chart.x + self.origin * self.layout.chart.w,
262                   self.layout.chart.y)
263        cx.line_to(self.layout.chart.x + self.origin * self.layout.chart.w,
264                   self.layout.chart.y + self.layout.chart.h)
265        cx.close_path()
266        cx.stroke()
267
268    def _renderYVal(self, cx, label, labelW, labelH, barX, barY, barW, barH):
269        y = barY + (barH / 2.0) + (labelH / 2.0)
270        if self.options.yvals.snapToOrigin:
271            x = barX + 2
272        elif self.options.yvals.inside:
273            x = barX + barW - (1.2 * labelW)
274        else:
275            x = barX + barW + 0.2 * labelW
276
277        # if the label doesn't fit to the left of the bar, put it to the right
278        if x < barX:
279            x = barX + barW + 0.2 * labelW
280
281        cx.move_to(x, y)
282        cx.show_text(label)
283
284    def _renderError(self, cx, barX, barY, barW, barH, value, error):
285        center = barY + (barH / 2.0)
286        errorHeight = max(barH * 0.1, 5.0)
287        top = center + errorHeight
288        bottom = center - errorHeight
289        errorSize = barW * error / value
290        right = barX + barW + errorSize
291        left = barX + barW - errorSize
292
293        cx.set_source_rgb(0, 0, 0)
294        cx.move_to(left, top)
295        cx.line_to(left, bottom)
296        cx.stroke()
297        cx.move_to(left, center)
298        cx.line_to(right, center)
299        cx.stroke()
300        cx.move_to(right, top)
301        cx.line_to(right, bottom)
302        cx.stroke()
303
304
305class Rect(object):
306
307    def __init__(self, x, y, w, h, xval, yval, name, yerr=0.0):
308        self.x, self.y, self.w, self.h = x, y, w, h
309        self.xval, self.yval, self.yerr = xval, yval, yerr
310        self.name = name
311
312    def __str__(self):
313        return ("<pycha.bar.Rect@(%.2f, %.2f) %.2fx%.2f (%.2f, %.2f, %.2f) %s>"
314                % (self.x, self.y, self.w, self.h,
315                   self.xval, self.yval, self.yerr,
316                   self.name))
317