1# -*- coding: utf-8 -*- 2# This file is part of pygal 3# 4# A python svg graph plotting library 5# Copyright © 2012-2016 Kozea 6# 7# This library is free software: you can redistribute it and/or modify it under 8# the terms of the GNU Lesser General Public License as published by the Free 9# Software Foundation, either version 3 of the License, or (at your option) any 10# later version. 11# 12# This library is distributed in the hope that it will be useful, but WITHOUT 13# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 14# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 15# details. 16# 17# You should have received a copy of the GNU Lesser General Public License 18# along with pygal. If not, see <http://www.gnu.org/licenses/>. 19 20""" 21Radar chart: As known as kiviat chart or spider chart is a polar line chart 22useful for multivariate observation. 23""" 24 25from __future__ import division 26 27from math import cos, pi 28 29from pygal._compat import is_str 30from pygal.adapters import none_to_zero, positive 31from pygal.graph.line import Line 32from pygal.util import cached_property, compute_scale, cut, deg, truncate 33from pygal.view import PolarLogView, PolarView 34 35 36class Radar(Line): 37 38 """Rada graph class""" 39 40 _adapters = [positive, none_to_zero] 41 42 def __init__(self, *args, **kwargs): 43 """Init custom vars""" 44 self._rmax = None 45 super(Radar, self).__init__(*args, **kwargs) 46 47 def _fill(self, values): 48 """Add extra values to fill the line""" 49 return values 50 51 @cached_property 52 def _values(self): 53 """Getter for series values (flattened)""" 54 if self.interpolate: 55 return [val[0] for serie in self.series 56 for val in serie.interpolated] 57 else: 58 return super(Line, self)._values 59 60 def _set_view(self): 61 """Assign a view to current graph""" 62 if self.logarithmic: 63 view_class = PolarLogView 64 else: 65 view_class = PolarView 66 67 self.view = view_class( 68 self.width - self.margin_box.x, 69 self.height - self.margin_box.y, 70 self._box) 71 72 def _x_axis(self, draw_axes=True): 73 """Override x axis to make it polar""" 74 if not self._x_labels or not self.show_x_labels: 75 return 76 77 axis = self.svg.node(self.nodes['plot'], class_="axis x web%s" % ( 78 ' always_show' if self.show_x_guides else '' 79 )) 80 format_ = lambda x: '%f %f' % x 81 center = self.view((0, 0)) 82 r = self._rmax 83 84 # Can't simply determine truncation 85 truncation = self.truncate_label or 25 86 87 for label, theta in self._x_labels: 88 major = label in self._x_labels_major 89 if not (self.show_minor_x_labels or major): 90 continue 91 guides = self.svg.node(axis, class_='guides') 92 end = self.view((r, theta)) 93 94 self.svg.node( 95 guides, 'path', 96 d='M%s L%s' % (format_(center), format_(end)), 97 class_='%s%sline' % ( 98 'axis ' if label == "0" else '', 99 'major ' if major else '')) 100 101 r_txt = (1 - self._box.__class__.margin) * self._box.ymax 102 pos_text = self.view((r_txt, theta)) 103 text = self.svg.node( 104 guides, 'text', 105 x=pos_text[0], 106 y=pos_text[1], 107 class_='major' if major else '') 108 text.text = truncate(label, truncation) 109 if text.text != label: 110 self.svg.node(guides, 'title').text = label 111 else: 112 self.svg.node( 113 guides, 'title', 114 ).text = self._x_format(theta) 115 116 angle = - theta + pi / 2 117 if cos(angle) < 0: 118 angle -= pi 119 text.attrib['transform'] = 'rotate(%f %s)' % ( 120 self.x_label_rotation or deg(angle), format_(pos_text)) 121 122 def _y_axis(self, draw_axes=True): 123 """Override y axis to make it polar""" 124 if not self._y_labels or not self.show_y_labels: 125 return 126 127 axis = self.svg.node(self.nodes['plot'], class_="axis y web") 128 129 for label, r in reversed(self._y_labels): 130 major = r in self._y_labels_major 131 if not (self.show_minor_y_labels or major): 132 continue 133 guides = self.svg.node(axis, class_='%sguides' % ( 134 'logarithmic ' if self.logarithmic else '' 135 )) 136 if self.show_y_guides: 137 self.svg.line( 138 guides, [self.view((r, theta)) for theta in self._x_pos], 139 close=True, 140 class_='%sguide line' % ( 141 'major ' if major else '')) 142 x, y = self.view((r, self._x_pos[0])) 143 x -= 5 144 text = self.svg.node( 145 guides, 'text', 146 x=x, 147 y=y, 148 class_='major' if major else '' 149 ) 150 text.text = label 151 152 if self.y_label_rotation: 153 text.attrib['transform'] = "rotate(%d %f %f)" % ( 154 self.y_label_rotation, x, y) 155 156 self.svg.node( 157 guides, 'title', 158 ).text = self._y_format(r) 159 160 def _compute(self): 161 """Compute r min max and labels position""" 162 delta = 2 * pi / self._len if self._len else 0 163 self._x_pos = [.5 * pi + i * delta for i in range(self._len + 1)] 164 for serie in self.all_series: 165 serie.points = [ 166 (v, self._x_pos[i]) 167 for i, v in enumerate(serie.values)] 168 if self.interpolate: 169 extended_x_pos = ( 170 [.5 * pi - delta] + self._x_pos) 171 extended_vals = (serie.values[-1:] + 172 serie.values) 173 serie.interpolated = list( 174 map(tuple, 175 map(reversed, 176 self._interpolate( 177 extended_x_pos, extended_vals)))) 178 179 # x labels space 180 self._box.margin *= 2 181 self._rmin = self.zero 182 self._rmax = self._max or 1 183 self._box.set_polar_box(self._rmin, self._rmax) 184 self._self_close = True 185 186 def _compute_y_labels(self): 187 y_pos = compute_scale( 188 self._rmin, self._rmax, self.logarithmic, self.order_min, 189 self.min_scale, self.max_scale / 2 190 ) 191 if self.y_labels: 192 self._y_labels = [] 193 for i, y_label in enumerate(self.y_labels): 194 if isinstance(y_label, dict): 195 pos = self._adapt(y_label.get('value')) 196 title = y_label.get('label', self._y_format(pos)) 197 elif is_str(y_label): 198 pos = self._adapt(y_pos[i]) 199 title = y_label 200 else: 201 pos = self._adapt(y_label) 202 title = self._y_format(pos) 203 self._y_labels.append((title, pos)) 204 self._rmin = min(self._rmin, min(cut(self._y_labels, 1))) 205 self._rmax = max(self._rmax, max(cut(self._y_labels, 1))) 206 self._box.set_polar_box(self._rmin, self._rmax) 207 208 else: 209 self._y_labels = list(zip(map(self._y_format, y_pos), y_pos)) 210