1# -*- encoding: utf-8 -*-
2#
3#
4# Copyright (C) 2002-2012 Jörg Lehmann <joerg@pyx-project.org>
5# Copyright (C) 2003-2011 Michael Schindler <m-schindler@users.sourceforge.net>
6# Copyright (C) 2002-2012 André Wobst <wobsta@pyx-project.org>
7#
8# This file is part of PyX (https://pyx-project.org/).
9#
10# PyX is free software; you can redistribute it and/or modify
11# it under the terms of the GNU General Public License as published by
12# the Free Software Foundation; either version 2 of the License, or
13# (at your option) any later version.
14#
15# PyX is distributed in the hope that it will be useful,
16# but WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18# GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with PyX; if not, write to the Free Software
22# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
23
24import functools, logging, math
25from pyx import attr, unit, text
26from pyx.graph.axis import painter, parter, positioner, rater, texter, tick
27
28logger = logging.getLogger("pyx")
29class _marker: pass
30
31
32class axisdata:
33    """axis data storage class
34
35    Instances of this class are used to store axis data local to the
36    graph. It will always contain an axispos instance provided by the
37    graph during initialization."""
38
39    def __init__(self, **kwargs):
40        for key, value in list(kwargs.items()):
41            setattr(self, key, value)
42
43
44class _axis:
45    """axis"""
46
47    def createlinked(self, data, positioner, graphtextengine, errorname, linkpainter):
48        canvas = painter.axiscanvas(self.painter, graphtextengine)
49        if linkpainter is not None:
50            linkpainter.paint(canvas, data, self, positioner)
51        return canvas
52
53
54class NoValidPartitionError(RuntimeError):
55
56    pass
57
58
59class _regularaxis(_axis):
60    """base implementation a regular axis
61
62    Regular axis have a continuous variable like linear axes,
63    logarithmic axes, time axes etc."""
64
65    def __init__(self, min=None, max=None, reverse=0, divisor=None, title=None,
66                       painter=painter.regular(), texter=texter.default(), linkpainter=painter.linked(),
67                       density=1, maxworse=2, manualticks=[], fallbackrange=None):
68        if min is not None and max is not None and min > max:
69            min, max, reverse = max, min, not reverse
70        self.min = min
71        self.max = max
72        self.reverse = reverse
73        self.divisor = divisor
74        self.title = title
75        self.painter = painter
76        self.texter = texter
77        self.linkpainter = linkpainter
78        self.density = density
79        self.maxworse = maxworse
80        self.manualticks = self.checkfraclist(manualticks)
81        self.fallbackrange = fallbackrange
82
83    def createdata(self, errorname):
84        return axisdata(min=self.min, max=self.max)
85
86    zero = 0.0
87
88    def adjustaxis(self, data, columndata, graphtextengine, errorname):
89        if self.min is None or self.max is None:
90            for value in columndata:
91                try:
92                    value = value + self.zero
93                except:
94                    pass
95                else:
96                    if self.min is None and (data.min is None or value < data.min):
97                        data.min = value
98                    if self.max is None and (data.max is None or value > data.max):
99                        data.max = value
100
101    def checkfraclist(self, fracs):
102        "orders a list of fracs, equal entries are not allowed"
103        if not len(fracs): return []
104        sorted = list(fracs)
105        sorted.sort()
106        last = sorted[0]
107        for item in sorted[1:]:
108            if last == item:
109                raise ValueError("duplicate entry found")
110            last = item
111        return sorted
112
113    def _create(self, data, positioner, graphtextengine, parter, rater, errorname):
114        errorname = " for axis %s" % errorname
115        if data.min is None or data.max is None:
116            raise RuntimeError("incomplete axis range%s" % errorname)
117        if data.max == data.min:
118            if self.fallbackrange is not None:
119                try:
120                    data.min, data.max = data.min - 0.5*self.fallbackrange, data.min + 0.5*self.fallbackrange
121                except TypeError:
122                    data.min, data.max = self.fallbackrange[0], self.fallbackrange[1]
123            else:
124                raise RuntimeError("zero axis range%s" % errorname)
125
126        if self.divisor is not None:
127            rational_divisor = tick.rational(self.divisor)
128            convert_tick = lambda x: float(x)*self.divisor
129        else:
130            convert_tick = lambda x: x
131
132        def layout(data):
133            if data.ticks:
134                self.adjustaxis(data, [convert_tick(data.ticks[0]), convert_tick(data.ticks[-1])], graphtextengine, errorname)
135            self.texter.labels(data.ticks)
136            if self.divisor:
137                for t in data.ticks:
138                    t *= rational_divisor
139            canvas = painter.axiscanvas(self.painter, graphtextengine)
140            if self.painter is not None:
141                self.painter.paint(canvas, data, self, positioner)
142            return canvas
143
144        if parter is None:
145            data.ticks = self.manualticks
146            return layout(data)
147
148        # a variant is a data copy with local modifications to test several partitions
149        @functools.total_ordering
150        class variant:
151            def __init__(self, data, **kwargs):
152                self.data = data
153                for key, value in list(kwargs.items()):
154                    setattr(self, key, value)
155
156            def __getattr__(self, key):
157                return getattr(data, key)
158
159            def __lt__(self, other):
160                # we can also sort variants by their rate
161                return self.rate < other.rate
162
163            def __eq__(self, other):
164                # we can also sort variants by their rate
165                return self.rate == other.rate
166
167        # build a list of variants
168        bestrate = None
169        if self.divisor is not None:
170            if data.min is not None:
171                data_min_divided = data.min/self.divisor
172            else:
173                data_min_divided = None
174            if data.max is not None:
175                data_max_divided = data.max/self.divisor
176            else:
177                data_max_divided = None
178            partfunctions = parter.partfunctions(data_min_divided, data_max_divided,
179                                                 self.min is None, self.max is None)
180        else:
181            partfunctions = parter.partfunctions(data.min, data.max,
182                                                 self.min is None, self.max is None)
183        variants = []
184        for partfunction in partfunctions:
185            worse = 0
186            while worse < self.maxworse:
187                worse += 1
188                ticks = partfunction()
189                if ticks is None:
190                    break
191                ticks = tick.mergeticklists(self.manualticks, ticks, mergeequal=0)
192                if ticks:
193                    rate = rater.rateticks(self, ticks, self.density)
194                    if rate is not None:
195                        if self.reverse:
196                            rate += rater.raterange(self.convert(data, convert_tick(ticks[0])) -
197                                                    self.convert(data, convert_tick(ticks[-1])), 1)
198                        else:
199                            rate += rater.raterange(self.convert(data, convert_tick(ticks[-1])) -
200                                                    self.convert(data, convert_tick(ticks[0])), 1)
201                        if bestrate is None or rate < bestrate:
202                            bestrate = rate
203                            worse = 0
204                        variants.append(variant(data, rate=rate, ticks=ticks))
205
206        if not variants:
207            raise RuntimeError("no axis partitioning found%s" % errorname)
208
209        if len(variants) == 1 or self.painter is None:
210            # When the painter is None, we could sort the variants here by their rating.
211            # However, we didn't did this so far and there is no real reason to change that.
212            data.ticks = variants[0].ticks
213            return layout(data)
214
215        # build the layout for best variants
216        for variant in variants:
217            variant.storedcanvas = None
218        variants.sort()
219        while not variants[0].storedcanvas:
220            variants[0].storedcanvas = layout(variants[0])
221            ratelayout = rater.ratelayout(variants[0].storedcanvas, self.density)
222            if ratelayout is None:
223                del variants[0]
224                if not variants:
225                    raise NoValidPartitionError("no valid axis partitioning found%s" % errorname)
226            else:
227                variants[0].rate += ratelayout
228            variants.sort()
229        self.adjustaxis(data, variants[0].ticks, graphtextengine, errorname)
230        data.ticks = variants[0].ticks
231        return variants[0].storedcanvas
232
233
234class linear(_regularaxis):
235    """linear axis"""
236
237    def __init__(self, parter=parter.autolinear(), rater=rater.linear(), **args):
238        _regularaxis.__init__(self, **args)
239        self.parter = parter
240        self.rater = rater
241
242    def convert(self, data, value):
243        """axis coordinates -> graph coordinates"""
244        if self.reverse:
245            return (data.max - float(value)) / (data.max - data.min)
246        else:
247            return (float(value) - data.min) / (data.max - data.min)
248
249    def create(self, data, positioner, graphtextengine, errorname):
250        return _regularaxis._create(self, data, positioner, graphtextengine, self.parter, self.rater, errorname)
251
252lin = linear
253
254
255class logarithmic(_regularaxis):
256    """logarithmic axis"""
257
258    def __init__(self, parter=parter.autologarithmic(), rater=rater.logarithmic(),
259                       linearparter=parter.autolinear(extendtick=None), **args):
260        _regularaxis.__init__(self, **args)
261        self.parter = parter
262        self.rater = rater
263        self.linearparter = linearparter
264
265    def convert(self, data, value):
266        """axis coordinates -> graph coordinates"""
267        # TODO: store log(data.min) and log(data.max)
268        if self.reverse:
269            return (math.log(data.max) - math.log(float(value))) / (math.log(data.max) - math.log(data.min))
270        else:
271            return (math.log(float(value)) - math.log(data.min)) / (math.log(data.max) - math.log(data.min))
272
273    def create(self, data, positioner, graphtextengine, errorname):
274        try:
275            return _regularaxis._create(self, data, positioner, graphtextengine, self.parter, self.rater, errorname)
276        except NoValidPartitionError:
277            if self.linearparter:
278                logger.warning("no valid logarithmic partitioning found for axis %s, switch to linear partitioning" % errorname)
279                return _regularaxis._create(self, data, positioner, graphtextengine, self.linearparter, self.rater, errorname)
280            raise
281
282log = logarithmic
283
284
285class subaxispositioner(positioner._positioner):
286    """a subaxis positioner"""
287
288    def __init__(self, basepositioner, subaxis):
289        self.basepositioner = basepositioner
290        self.vmin = subaxis.vmin
291        self.vmax = subaxis.vmax
292        self.vminover = subaxis.vminover
293        self.vmaxover = subaxis.vmaxover
294
295    def vbasepath(self, v1=None, v2=None):
296        if v1 is not None:
297            v1 = self.vmin+v1*(self.vmax-self.vmin)
298        else:
299            v1 = self.vminover
300        if v2 is not None:
301            v2 = self.vmin+v2*(self.vmax-self.vmin)
302        else:
303            v2 = self.vmaxover
304        return self.basepositioner.vbasepath(v1, v2)
305
306    def vgridpath(self, v):
307        return self.basepositioner.vgridpath(self.vmin+v*(self.vmax-self.vmin))
308
309    def vtickpoint_pt(self, v, axis=None):
310        return self.basepositioner.vtickpoint_pt(self.vmin+v*(self.vmax-self.vmin))
311
312    def vtickdirection(self, v, axis=None):
313        return self.basepositioner.vtickdirection(self.vmin+v*(self.vmax-self.vmin))
314
315
316class bar(_axis):
317
318    def __init__(self, subaxes=None, defaultsubaxis=linear(painter=None, linkpainter=None, parter=None),
319                       dist=0.5, firstdist=None, lastdist=None, title=None, reverse=0,
320                       painter=painter.bar(), linkpainter=painter.linkedbar()):
321        self.subaxes = subaxes
322        self.defaultsubaxis = defaultsubaxis
323        self.dist = dist
324        if firstdist is not None:
325            self.firstdist = firstdist
326        else:
327            self.firstdist = 0.5 * dist
328        if lastdist is not None:
329            self.lastdist = lastdist
330        else:
331            self.lastdist = 0.5 * dist
332        self.title = title
333        self.reverse = reverse
334        self.painter = painter
335        self.linkpainter = linkpainter
336
337    def createdata(self, errorname):
338        data = axisdata(size=self.firstdist+self.lastdist-self.dist, subaxes={}, names=[])
339        return data
340
341    def addsubaxis(self, data, name, subaxis, graphtextengine, errorname):
342        subaxis = anchoredaxis(subaxis, graphtextengine, "%s, subaxis %s" % (errorname, name))
343        subaxis.setcreatecall(lambda: None)
344        subaxis.sized = hasattr(subaxis.data, "size")
345        if subaxis.sized:
346            data.size += subaxis.data.size
347        else:
348            data.size += 1
349        data.size += self.dist
350        data.subaxes[name] = subaxis
351        if self.reverse:
352            data.names.insert(0, name)
353        else:
354            data.names.append(name)
355
356    def adjustaxis(self, data, columndata, graphtextengine, errorname):
357        for value in columndata:
358
359            # some checks and error messages
360            try:
361                len(value)
362            except:
363                raise ValueError("tuple expected by bar axis '%s'" % errorname)
364            try:
365                value + ""
366            except:
367                pass
368            else:
369                raise ValueError("tuple expected by bar axis '%s'" % errorname)
370            assert len(value) == 2, "tuple of size two expected by bar axis '%s'" % errorname
371
372            name = value[0]
373            if name is not None and name not in data.names:
374                if self.subaxes:
375                    if self.subaxes[name] is not None:
376                        self.addsubaxis(data, name, self.subaxes[name], graphtextengine, errorname)
377                else:
378                    self.addsubaxis(data, name, self.defaultsubaxis, graphtextengine, errorname)
379        for name in data.names:
380            subaxis = data.subaxes[name]
381            if subaxis.sized:
382                data.size -= subaxis.data.size
383            subaxis.axis.adjustaxis(subaxis.data,
384                                    [value[1] for value in columndata if value[0] == name],
385                                    graphtextengine,
386                                    "%s, subaxis %s" % (errorname, name))
387            if subaxis.sized:
388                data.size += subaxis.data.size
389
390    def convert(self, data, value):
391        if value[0] is None:
392            raise ValueError
393        axis = data.subaxes[value[0]]
394        vmin = axis.vmin
395        vmax = axis.vmax
396        return axis.vmin + axis.convert(value[1]) * (axis.vmax - axis.vmin)
397
398    def create(self, data, positioner, graphtextengine, errorname):
399        canvas = painter.axiscanvas(self.painter, graphtextengine)
400        v = 0
401        position = self.firstdist
402        for name in data.names:
403            subaxis = data.subaxes[name]
404            subaxis.vmin = position / float(data.size)
405            if subaxis.sized:
406                position += subaxis.data.size
407            else:
408                position += 1
409            subaxis.vmax = position / float(data.size)
410            position += 0.5*self.dist
411            subaxis.vminover = v
412            if name == data.names[-1]:
413                subaxis.vmaxover = 1
414            else:
415                subaxis.vmaxover = position / float(data.size)
416            subaxis.setpositioner(subaxispositioner(positioner, subaxis))
417            subaxis.create()
418            for layer, subcanvas in list(subaxis.canvas.layers.items()):
419                canvas.layer(layer).insert(subcanvas)
420            assert len(subaxis.canvas.layers) == len(subaxis.canvas.items)
421            if canvas.extent_pt < subaxis.canvas.extent_pt:
422                canvas.extent_pt = subaxis.canvas.extent_pt
423            position += 0.5*self.dist
424            v = subaxis.vmaxover
425        if self.painter is not None:
426            self.painter.paint(canvas, data, self, positioner)
427        return canvas
428
429    def createlinked(self, data, positioner, graphtextengine, errorname, linkpainter):
430        canvas = painter.axiscanvas(self.painter, graphtextengine)
431        for name in data.names:
432            subaxis = data.subaxes[name]
433            subaxis = linkedaxis(subaxis, name)
434            subaxis.setpositioner(subaxispositioner(positioner, data.subaxes[name]))
435            subaxis.create()
436            for layer, subcanvas in list(subaxis.canvas.layers.items()):
437                canvas.layer(layer).insert(subcanvas)
438            assert len(subaxis.canvas.layers) == len(subaxis.canvas.items)
439            if canvas.extent_pt < subaxis.canvas.extent_pt:
440                canvas.extent_pt = subaxis.canvas.extent_pt
441        if linkpainter is not None:
442            linkpainter.paint(canvas, data, self, positioner)
443        return canvas
444
445
446class nestedbar(bar):
447
448    def __init__(self, defaultsubaxis=bar(dist=0, painter=None, linkpainter=None), **kwargs):
449        bar.__init__(self, defaultsubaxis=defaultsubaxis, **kwargs)
450
451
452class split(bar):
453
454    def __init__(self, defaultsubaxis=linear(),
455                       firstdist=0, lastdist=0,
456                       painter=painter.split(), linkpainter=painter.linkedsplit(), **kwargs):
457        bar.__init__(self, defaultsubaxis=defaultsubaxis,
458                           firstdist=firstdist, lastdist=lastdist,
459                           painter=painter, linkpainter=linkpainter, **kwargs)
460
461
462class sizedlinear(linear):
463
464    def __init__(self, size=1, **kwargs):
465        linear.__init__(self, **kwargs)
466        self.size = size
467
468    def createdata(self, errorname):
469        data = linear.createdata(self, errorname)
470        data.size = self.size
471        return data
472
473sizedlin = sizedlinear
474
475
476class autosizedlinear(linear):
477
478    def __init__(self, parter=parter.autolinear(extendtick=None), **kwargs):
479        linear.__init__(self, parter=parter, **kwargs)
480
481    def createdata(self, errorname):
482        data = linear.createdata(self, errorname)
483        try:
484            data.size = data.max - data.min
485        except:
486            data.size = 0
487        return data
488
489    def adjustaxis(self, data, columndata, graphtextengine, errorname):
490        linear.adjustaxis(self, data, columndata, graphtextengine, errorname)
491        try:
492            data.size = data.max - data.min
493        except:
494            data.size = 0
495
496    def create(self, data, positioner, graphtextengine, errorname):
497        min = data.min
498        max = data.max
499        canvas = linear.create(self, data, positioner, graphtextengine, errorname)
500        if min != data.min or max != data.max:
501            raise RuntimeError("range change during axis creation of autosized linear axis")
502        return canvas
503
504autosizedlin = autosizedlinear
505
506
507class anchoredaxis:
508
509    def __init__(self, axis, graphtextengine, errorname):
510        assert not isinstance(axis, anchoredaxis), errorname
511        self.axis = axis
512        self.errorname = errorname
513        self.graphtextengine = graphtextengine
514        self.data = axis.createdata(self.errorname)
515        self.canvas = None
516        self.positioner = None
517
518    def setcreatecall(self, function, *args, **kwargs):
519        self._createfunction = function
520        self._createargs = args
521        self._createkwargs = kwargs
522
523    def docreate(self):
524        if not self.canvas:
525            self._createfunction(*self._createargs, **self._createkwargs)
526
527    def setpositioner(self, positioner):
528        assert positioner is not None, self.errorname
529        assert self.positioner is None, self.errorname
530        self.positioner = positioner
531
532    def convert(self, x):
533        self.docreate()
534        return self.axis.convert(self.data, x)
535
536    def adjustaxis(self, columndata):
537        if self.canvas is None:
538            self.axis.adjustaxis(self.data, columndata, self.graphtextengine, self.errorname)
539        else:
540            logger.warning("ignore axis range adjustment of already created axis '%s'"  % self.errorname)
541
542    def vbasepath(self, v1=None, v2=None):
543        return self.positioner.vbasepath(v1=v1, v2=v2)
544
545    def basepath(self, x1=None, x2=None):
546        self.docreate()
547        if x1 is None:
548            if x2 is None:
549                return self.positioner.vbasepath()
550            else:
551                return self.positioner.vbasepath(v2=self.axis.convert(self.data, x2))
552        else:
553            if x2 is None:
554                return self.positioner.vbasepath(v1=self.axis.convert(self.data, x1))
555            else:
556                return self.positioner.vbasepath(v1=self.axis.convert(self.data, x1),
557                                                 v2=self.axis.convert(self.data, x2))
558
559    def vgridpath(self, v):
560        return self.positioner.vgridpath(v)
561
562    def gridpath(self, x):
563        self.docreate()
564        return self.positioner.vgridpath(self.axis.convert(self.data, x))
565
566    def vtickpoint_pt(self, v):
567        return self.positioner.vtickpoint_pt(v)
568
569    def vtickpoint(self, v):
570        return self.positioner.vtickpoint_pt(v) * unit.t_pt
571
572    def tickpoint_pt(self, x):
573        self.docreate()
574        return self.positioner.vtickpoint_pt(self.axis.convert(self.data, x))
575
576    def tickpoint(self, x):
577        self.docreate()
578        x_pt, y_pt = self.positioner.vtickpoint_pt(self.axis.convert(self.data, x))
579        return  x_pt * unit.t_pt, y_pt * unit.t_pt
580
581    def vtickdirection(self, v):
582        return self.positioner.vtickdirection(v)
583
584    def tickdirection(self, x):
585        self.docreate()
586        return self.positioner.vtickdirection(self.axis.convert(self.data, x))
587
588    def create(self):
589        if self.canvas is None:
590            assert self.positioner is not None, self.errorname
591            self.canvas = self.axis.create(self.data, self.positioner, self.graphtextengine, self.errorname)
592        return self.canvas
593
594
595class linkedaxis(anchoredaxis):
596
597    def __init__(self, linkedaxis=None, errorname="manual-linked", painter=_marker):
598        self.painter = painter
599        self.linkedto = None
600        self.errorname = errorname
601        self.canvas = None
602        self.positioner = None
603        if linkedaxis:
604            self.setlinkedaxis(linkedaxis)
605
606    def setlinkedaxis(self, linkedaxis):
607        assert isinstance(linkedaxis, anchoredaxis), self.errorname
608        self.linkedto = linkedaxis
609        self.axis = linkedaxis.axis
610        self.graphtextengine = self.linkedto.graphtextengine
611        self.errorname = "%s (linked to %s)" % (self.errorname, linkedaxis.errorname)
612        self.data = linkedaxis.data
613        if self.painter is _marker:
614            self.painter = linkedaxis.axis.linkpainter
615
616    def create(self):
617        assert self.linkedto is not None, self.errorname
618        assert self.positioner is not None, self.errorname
619        if self.canvas is None:
620            self.linkedto.docreate()
621            self.canvas = self.axis.createlinked(self.data, self.positioner, self.graphtextengine, self.errorname, self.painter)
622        return self.canvas
623
624
625class anchoredpathaxis(anchoredaxis):
626    """an anchored axis along a path"""
627
628    def __init__(self, path, axis, **kwargs):
629        anchoredaxis.__init__(self, axis, text.defaulttextengine, "pathaxis")
630        self.setpositioner(positioner.pathpositioner(path, **kwargs))
631        self.create()
632
633def pathaxis(*args, **kwargs):
634    """creates an axiscanvas for an axis along a path"""
635    return anchoredpathaxis(*args, **kwargs).canvas
636
637