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"""Projection and bounding helpers"""
21
22from __future__ import division
23
24from math import cos, log10, pi, sin
25
26
27class Margin(object):
28
29    """Class reprensenting a margin (top, right, left, bottom)"""
30
31    def __init__(self, top, right, bottom, left):
32        """Create the margin object from the top, right, left, bottom margin"""
33        self.top = top
34        self.right = right
35        self.bottom = bottom
36        self.left = left
37
38    @property
39    def x(self):
40        """Helper for total x margin"""
41        return self.left + self.right
42
43    @property
44    def y(self):
45        """Helper for total y margin"""
46        return self.top + self.bottom
47
48
49class Box(object):
50
51    """Chart boundings"""
52
53    margin = .02
54
55    def __init__(self, xmin=0, ymin=0, xmax=1, ymax=1):
56        """
57        Create the chart bounds with min max horizontal
58        and vertical values
59        """
60        self._xmin = xmin
61        self._ymin = ymin
62        self._xmax = xmax
63        self._ymax = ymax
64
65    def set_polar_box(self, rmin=0, rmax=1, tmin=0, tmax=2 * pi):
66        """Helper for polar charts"""
67        self._rmin = rmin
68        self._rmax = rmax
69        self._tmin = tmin
70        self._tmax = tmax
71        self.xmin = self.ymin = rmin - rmax
72        self.xmax = self.ymax = rmax - rmin
73
74    @property
75    def xmin(self):
76        """X minimum getter"""
77        return self._xmin
78
79    @xmin.setter
80    def xmin(self, value):
81        """X minimum setter"""
82        if value is not None:
83            self._xmin = value
84
85    @property
86    def ymin(self):
87        """Y minimum getter"""
88        return self._ymin
89
90    @ymin.setter
91    def ymin(self, value):
92        """Y minimum setter"""
93        if value is not None:
94            self._ymin = value
95
96    @property
97    def xmax(self):
98        """X maximum getter"""
99        return self._xmax
100
101    @xmax.setter
102    def xmax(self, value):
103        """X maximum setter"""
104        if value is not None:
105            self._xmax = value
106
107    @property
108    def ymax(self):
109        """Y maximum getter"""
110        return self._ymax
111
112    @ymax.setter
113    def ymax(self, value):
114        """Y maximum setter"""
115        if value or self.ymin:
116            self._ymax = value
117
118    @property
119    def width(self):
120        """Helper for box width"""
121        return self.xmax - self.xmin
122
123    @property
124    def height(self):
125        """Helper for box height"""
126        return self.ymax - self.ymin
127
128    def swap(self):
129        """Return the box (for horizontal graphs)"""
130        self.xmin, self.ymin = self.ymin, self.xmin
131        self.xmax, self.ymax = self.ymax, self.xmax
132
133    def fix(self, with_margin=True):
134        """Correct box when no values and take margin in account"""
135        if not self.width:
136            self.xmax = self.xmin + 1
137        if not self.height:
138            self.ymin /= 2
139            self.ymax += self.ymin
140        xmargin = self.margin * self.width
141        self.xmin -= xmargin
142        self.xmax += xmargin
143        if with_margin:
144            ymargin = self.margin * self.height
145            self.ymin -= ymargin
146            self.ymax += ymargin
147
148
149class View(object):
150
151    """Projection base class"""
152
153    def __init__(self, width, height, box):
154        """Create the view with a width an height and a box bounds"""
155        self.width = width
156        self.height = height
157        self.box = box
158        self.box.fix()
159
160    def x(self, x):
161        """Project x"""
162        if x is None:
163            return None
164        return self.width * (x - self.box.xmin) / self.box.width
165
166    def y(self, y):
167        """Project y"""
168        if y is None:
169            return None
170        return (self.height - self.height *
171                (y - self.box.ymin) / self.box.height)
172
173    def __call__(self, xy):
174        """Project x and y"""
175        x, y = xy
176        return (self.x(x), self.y(y))
177
178
179class ReverseView(View):
180
181    """Same as view but reversed vertically"""
182
183    def y(self, y):
184        """Project reversed y"""
185        if y is None:
186            return None
187        return (self.height * (y - self.box.ymin) / self.box.height)
188
189
190class HorizontalView(View):
191
192    """Same as view but transposed"""
193
194    def __init__(self, width, height, box):
195        """Create the view with a width an height and a box bounds"""
196        self._force_vertical = None
197        self.width = width
198        self.height = height
199
200        self.box = box
201        self.box.fix()
202        self.box.swap()
203
204    def x(self, x):
205        """Project x as y"""
206        if x is None:
207            return None
208        if self._force_vertical:
209            return super(HorizontalView, self).x(x)
210        return super(HorizontalView, self).y(x)
211
212    def y(self, y):
213        """Project y as x"""
214        if y is None:
215            return None
216        if self._force_vertical:
217            return super(HorizontalView, self).y(y)
218        return super(HorizontalView, self).x(y)
219
220
221class PolarView(View):
222
223    """Polar projection for pie like graphs"""
224
225    def __call__(self, rhotheta):
226        """Project rho and theta"""
227        if None in rhotheta:
228            return None, None
229        rho, theta = rhotheta
230        return super(PolarView, self).__call__(
231            (rho * cos(theta), rho * sin(theta)))
232
233
234class PolarLogView(View):
235
236    """Logarithmic polar projection"""
237
238    def __init__(self, width, height, box):
239        """Create the view with a width an height and a box bounds"""
240        super(PolarLogView, self).__init__(width, height, box)
241        if not hasattr(box, '_rmin') or not hasattr(box, '_rmax'):
242            raise Exception(
243                'Box must be set with set_polar_box for polar charts')
244
245        self.log10_rmax = log10(self.box._rmax)
246        self.log10_rmin = log10(self.box._rmin)
247        if self.log10_rmin == self.log10_rmax:
248            self.log10_rmax = self.log10_rmin + 1
249
250    def __call__(self, rhotheta):
251        """Project rho and theta"""
252        if None in rhotheta:
253            return None, None
254        rho, theta = rhotheta
255        # Center case
256        if rho == 0:
257            return super(PolarLogView, self).__call__((0, 0))
258        rho = (self.box._rmax - self.box._rmin) * (
259            log10(rho) - self.log10_rmin) / (
260            self.log10_rmax - self.log10_rmin)
261        return super(PolarLogView, self).__call__(
262            (rho * cos(theta), rho * sin(theta)))
263
264
265class PolarThetaView(View):
266
267    """Logarithmic polar projection"""
268
269    def __init__(self, width, height, box, aperture=pi / 3):
270        """Create the view with a width an height and a box bounds"""
271        super(PolarThetaView, self).__init__(width, height, box)
272        self.aperture = aperture
273        if not hasattr(box, '_tmin') or not hasattr(box, '_tmax'):
274            raise Exception(
275                'Box must be set with set_polar_box for polar charts')
276
277    def __call__(self, rhotheta):
278        """Project rho and theta"""
279        if None in rhotheta:
280            return None, None
281        rho, theta = rhotheta
282        start = 3 * pi / 2 + self.aperture / 2
283        theta = start + (2 * pi - self.aperture) * (
284            theta - self.box._tmin) / (
285                self.box._tmax - self.box._tmin)
286        return super(PolarThetaView, self).__call__(
287            (rho * cos(theta), rho * sin(theta)))
288
289
290class PolarThetaLogView(View):
291
292    """Logarithmic polar projection"""
293
294    def __init__(self, width, height, box, aperture=pi / 3):
295        """Create the view with a width an height and a box bounds"""
296        super(PolarThetaLogView, self).__init__(width, height, box)
297        self.aperture = aperture
298        if not hasattr(box, '_tmin') or not hasattr(box, '_tmax'):
299            raise Exception(
300                'Box must be set with set_polar_box for polar charts')
301        self.log10_tmax = log10(self.box._tmax) if self.box._tmax > 0 else 0
302        self.log10_tmin = log10(self.box._tmin) if self.box._tmin > 0 else 0
303        if self.log10_tmin == self.log10_tmax:
304            self.log10_tmax = self.log10_tmin + 1
305
306    def __call__(self, rhotheta):
307        """Project rho and theta"""
308        if None in rhotheta:
309            return None, None
310        rho, theta = rhotheta
311        # Center case
312        if theta == 0:
313            return super(PolarThetaLogView, self).__call__((0, 0))
314        theta = self.box._tmin + (self.box._tmax - self.box._tmin) * (
315            log10(theta) - self.log10_tmin) / (
316            self.log10_tmax - self.log10_tmin)
317
318        start = 3 * pi / 2 + self.aperture / 2
319        theta = start + (2 * pi - self.aperture) * (
320            theta - self.box._tmin) / (
321                self.box._tmax - self.box._tmin)
322
323        return super(PolarThetaLogView, self).__call__(
324            (rho * cos(theta), rho * sin(theta)))
325
326
327class LogView(View):
328
329    """Y Logarithmic projection"""
330
331    # Do not want to call the parent here
332    def __init__(self, width, height, box):
333        """Create the view with a width an height and a box bounds"""
334        self.width = width
335        self.height = height
336        self.box = box
337        self.log10_ymax = log10(self.box.ymax) if self.box.ymax > 0 else 0
338        self.log10_ymin = log10(self.box.ymin) if self.box.ymin > 0 else 0
339        if self.log10_ymin == self.log10_ymax:
340            self.log10_ymax = self.log10_ymin + 1
341        self.box.fix(False)
342
343    def y(self, y):
344        """Project y"""
345        if y is None or y <= 0 or self.log10_ymax - self.log10_ymin == 0:
346            return 0
347        return (self.height - self.height *
348                (log10(y) - self.log10_ymin) / (
349                    self.log10_ymax - self.log10_ymin))
350
351
352class XLogView(View):
353
354    """X logarithmic projection"""
355
356    # Do not want to call the parent here
357    def __init__(self, width, height, box):
358        """Create the view with a width an height and a box bounds"""
359        self.width = width
360        self.height = height
361        self.box = box
362        self.log10_xmax = log10(self.box.xmax) if self.box.xmax > 0 else 0
363        self.log10_xmin = log10(self.box.xmin) if self.box.xmin > 0 else 0
364        self.box.fix(False)
365
366    def x(self, x):
367        """Project x"""
368        if x is None or x <= 0 or self.log10_xmax - self.log10_xmin == 0:
369            return None
370        return (self.width *
371                (log10(x) - self.log10_xmin) /
372                (self.log10_xmax - self.log10_xmin))
373
374
375class XYLogView(XLogView, LogView):
376
377    """X and Y logarithmic projection"""
378
379    def __init__(self, width, height, box):
380        """Create the view with a width an height and a box bounds"""
381        self.width = width
382        self.height = height
383        self.box = box
384        self.log10_ymax = log10(self.box.ymax) if self.box.ymax > 0 else 0
385        self.log10_ymin = log10(self.box.ymin) if self.box.ymin > 0 else 0
386        self.log10_xmax = log10(self.box.xmax) if self.box.xmax > 0 else 0
387        self.log10_xmin = log10(self.box.xmin) if self.box.xmin > 0 else 0
388        self.box.fix(False)
389
390
391class HorizontalLogView(XLogView):
392
393    """Transposed Logarithmic projection"""
394
395    # Do not want to call the parent here
396    def __init__(self, width, height, box):
397        """Create the view with a width an height and a box bounds"""
398        self._force_vertical = None
399        self.width = width
400        self.height = height
401        self.box = box
402        self.log10_xmax = log10(self.box.ymax) if self.box.ymax > 0 else 0
403        self.log10_xmin = log10(self.box.ymin) if self.box.ymin > 0 else 0
404        if self.log10_xmin == self.log10_xmax:
405            self.log10_xmax = self.log10_xmin + 1
406        self.box.fix(False)
407        self.box.swap()
408
409    def x(self, x):
410        """Project x as y"""
411        if x is None:
412            return None
413        if self._force_vertical:
414            return super(HorizontalLogView, self).x(x)
415        return super(XLogView, self).y(x)
416
417    def y(self, y):
418        """Project y as x"""
419        if y is None:
420            return None
421        if self._force_vertical:
422            return super(XLogView, self).y(y)
423        return super(HorizontalLogView, self).x(y)
424