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