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