1#Copyright ReportLab Europe Ltd. 2000-2017
2#see license.txt for license details
3#history https://hg.reportlab.com/hg-public/reportlab/log/tip/src/reportlab/graphics/charts/utils.py
4
5__version__='3.3.0'
6__doc__="Utilities used here and there."
7from time import mktime, gmtime, strftime
8from math import log10, pi, floor, sin, cos, sqrt, hypot
9import weakref
10from reportlab.graphics.shapes import transformPoint, transformPoints, inverse, Ellipse, Group, String, Path, numericXShift
11from reportlab.lib.utils import flatten
12from reportlab.pdfbase.pdfmetrics import stringWidth
13
14### Dinu's stuff used in some line plots (likely to vansih).
15def mkTimeTuple(timeString):
16    "Convert a 'dd/mm/yyyy' formatted string to a tuple for use in the time module."
17
18    L = [0] * 9
19    dd, mm, yyyy = list(map(int, timeString.split('/')))
20    L[:3] = [yyyy, mm, dd]
21
22    return tuple(L)
23
24def str2seconds(timeString):
25    "Convert a number of seconds since the epoch into a date string."
26
27    return mktime(mkTimeTuple(timeString))
28
29def seconds2str(seconds):
30    "Convert a date string into the number of seconds since the epoch."
31
32    return strftime('%Y-%m-%d', gmtime(seconds))
33
34### Aaron's rounding function for making nice values on axes.
35def nextRoundNumber(x):
36    """Return the first 'nice round number' greater than or equal to x
37
38    Used in selecting apropriate tick mark intervals; we say we want
39    an interval which places ticks at least 10 points apart, work out
40    what that is in chart space, and ask for the nextRoundNumber().
41    Tries the series 1,2,5,10,20,50,100.., going up or down as needed.
42    """
43
44    #guess to nearest order of magnitude
45    if x in (0, 1):
46        return x
47
48    if x < 0:
49        return -1.0 * nextRoundNumber(-x)
50    else:
51        lg = int(log10(x))
52
53        if lg == 0:
54            if x < 1:
55                base = 0.1
56            else:
57                base = 1.0
58        elif lg < 0:
59            base = 10.0 ** (lg - 1)
60        else:
61            base = 10.0 ** lg    # e.g. base(153) = 100
62        # base will always be lower than x
63
64        if base >= x:
65            return base * 1.0
66        elif (base * 2) >= x:
67            return base * 2.0
68        elif (base * 5) >= x:
69            return base * 5.0
70        else:
71            return base * 10.0
72
73_intervals=(.1, .2, .25, .5)
74_j_max=len(_intervals)-1
75def find_interval(lo,hi,I=5):
76    'determine tick parameters for range [lo, hi] using I intervals'
77
78    if lo >= hi:
79        if lo==hi:
80            if lo==0:
81                lo = -.1
82                hi =  .1
83            else:
84                lo = 0.9*lo
85                hi = 1.1*hi
86        else:
87            raise ValueError("lo>hi")
88    x=(hi - lo)/float(I)
89    b= (x>0 and (x<1 or x>10)) and 10**floor(log10(x)) or 1
90    b = b
91    while 1:
92        a = x/b
93        if a<=_intervals[-1]: break
94        b = b*10
95
96    j = 0
97    while a>_intervals[j]: j = j + 1
98
99    while 1:
100        ss = _intervals[j]*b
101        n = lo/ss
102        l = int(n)-(n<0)
103        n = ss*l
104        x = ss*(l+I)
105        a = I*ss
106        if n>0:
107            if a>=hi:
108                n = 0.0
109                x = a
110        elif hi<0:
111            a = -a
112            if lo>a:
113                n = a
114                x = 0
115        if hi<=x and n<=lo: break
116        j = j + 1
117        if j>_j_max:
118            j = 0
119            b = b*10
120    return n, x, ss, lo - n + x - hi
121
122def find_good_grid(lower,upper,n=(4,5,6,7,8,9), grid=None):
123    if grid:
124        t = divmod(lower,grid)[0] * grid
125        hi, z = divmod(upper,grid)
126        if z>1e-8: hi = hi+1
127        hi = hi*grid
128    else:
129        try:
130            n[0]
131        except TypeError:
132            n = range(max(1,n-2),max(n+3,2))
133
134        w = 1e308
135        for i in n:
136            z=find_interval(lower,upper,i)
137            if z[3]<w:
138                t, hi, grid = z[:3]
139                w=z[3]
140    return t, hi, grid
141
142def ticks(lower, upper, n=(4,5,6,7,8,9), split=1, percent=0, grid=None, labelVOffset=0):
143    '''
144    return tick positions and labels for range lower<=x<=upper
145    n=number of intervals to try (can be a list or sequence)
146    split=1 return ticks then labels else (tick,label) pairs
147    '''
148    t, hi, grid = find_good_grid(lower, upper, n, grid)
149    power = floor(log10(grid))
150    if power==0: power = 1
151    w = grid/10.**power
152    w = int(w)!=w
153
154    if power > 3 or power < -3:
155        format = '%+'+repr(w+7)+'.0e'
156    else:
157        if power >= 0:
158            digits = int(power)+w
159            format = '%' + repr(digits)+'.0f'
160        else:
161            digits = w-int(power)
162            format = '%'+repr(digits+2)+'.'+repr(digits)+'f'
163
164    if percent: format=format+'%%'
165    T = []
166    n = int(float(hi-t)/grid+0.1)+1
167    if split:
168        labels = []
169        for i in range(n):
170            v = t+grid*i
171            T.append(v)
172            labels.append(format % (v+labelVOffset))
173        return T, labels
174    else:
175        for i in range(n):
176            v = t+grid*i
177            T.append((v, format % (v+labelVOffset)))
178        return T
179
180def findNones(data):
181    m = len(data)
182    if None in data:
183        b = 0
184        while b<m and data[b] is None:
185            b += 1
186        if b==m: return data
187        l = m-1
188        while data[l] is None:
189            l -= 1
190        l+=1
191        if b or l: data = data[b:l]
192        I = [i for i in range(len(data)) if data[i] is None]
193        for i in I:
194            data[i] = 0.5*(data[i-1]+data[i+1])
195        return b, l, data
196    return 0,m,data
197
198def pairFixNones(pairs):
199    Y = [x[1] for x in pairs]
200    b,l,nY = findNones(Y)
201    m = len(Y)
202    if b or l<m or nY!=Y:
203        if b or l<m: pairs = pairs[b:l]
204        pairs = [(x[0],y) for x,y in zip(pairs,nY)]
205    return pairs
206
207def maverage(data,n=6):
208    data = (n-1)*[data[0]]+data
209    data = [float(sum(data[i-n:i]))/n for i in range(n,len(data)+1)]
210    return data
211
212def pairMaverage(data,n=6):
213    return [(x[0],s) for x,s in zip(data, maverage([x[1] for x in data],n))]
214
215class DrawTimeCollector(object):
216    '''
217    generic mechanism for collecting information about nodes at the time they are about to be drawn
218    '''
219    def __init__(self,formats=['gif']):
220        self._nodes = weakref.WeakKeyDictionary()
221        self.clear()
222        self._pmcanv = None
223        self.formats = formats
224        self.disabled = False
225
226    def clear(self):
227        self._info = []
228        self._info_append = self._info.append
229
230    def record(self,func,node,*args,**kwds):
231        self._nodes[node] = (func,args,kwds)
232        node.__dict__['_drawTimeCallback'] = self
233
234    def __call__(self,node,canvas,renderer):
235        func = self._nodes.get(node,None)
236        if func:
237            func, args, kwds = func
238            i = func(node,canvas,renderer, *args, **kwds)
239            if i is not None: self._info_append(i)
240
241    @staticmethod
242    def rectDrawTimeCallback(node,canvas,renderer,**kwds):
243        A = getattr(canvas,'ctm',None)
244        if not A: return
245        x1 = node.x
246        y1 = node.y
247        x2 = x1 + node.width
248        y2 = y1 + node.height
249
250        D = kwds.copy()
251        D['rect']=DrawTimeCollector.transformAndFlatten(A,((x1,y1),(x2,y2)))
252        return D
253
254    @staticmethod
255    def transformAndFlatten(A,p):
256        ''' transform an flatten a list of points
257        A   transformation matrix
258        p   points [(x0,y0),....(xk,yk).....]
259        '''
260        if tuple(A)!=(1,0,0,1,0,0):
261            iA = inverse(A)
262            p = transformPoints(iA,p)
263        return tuple(flatten(p))
264
265    @property
266    def pmcanv(self):
267        if not self._pmcanv:
268            import renderPM
269            self._pmcanv = renderPM.PMCanvas(1,1)
270        return self._pmcanv
271
272    def wedgeDrawTimeCallback(self,node,canvas,renderer,**kwds):
273        A = getattr(canvas,'ctm',None)
274        if not A: return
275        if isinstance(node,Ellipse):
276            c = self.pmcanv
277            c.ellipse(node.cx, node.cy, node.rx,node.ry)
278            p = c.vpath
279            p = [(x[1],x[2]) for x in p]
280        else:
281            p = node.asPolygon().points
282            p = [(p[i],p[i+1]) for i in range(0,len(p),2)]
283
284        D = kwds.copy()
285        D['poly'] = self.transformAndFlatten(A,p)
286        return D
287
288    def save(self,fnroot):
289        '''
290        save the current information known to this collector
291        fnroot is the root name of a resource to name the saved info
292        override this to get the right semantics for your collector
293        '''
294        import pprint
295        f=open(fnroot+'.default-collector.out','w')
296        try:
297            pprint.pprint(self._info,f)
298        finally:
299            f.close()
300
301def xyDist(xxx_todo_changeme, xxx_todo_changeme1 ):
302    '''return distance between two points'''
303    (x0,y0) = xxx_todo_changeme
304    (x1,y1) = xxx_todo_changeme1
305    return hypot((x1-x0),(y1-y0))
306
307def lineSegmentIntersect(xxx_todo_changeme2, xxx_todo_changeme3, xxx_todo_changeme4, xxx_todo_changeme5
308                ):
309    (x00,y00) = xxx_todo_changeme2
310    (x01,y01) = xxx_todo_changeme3
311    (x10,y10) = xxx_todo_changeme4
312    (x11,y11) = xxx_todo_changeme5
313    p = x00,y00
314    r = x01-x00,y01-y00
315
316
317    q = x10,y10
318    s = x11-x10,y11-y10
319
320    rs = float(r[0]*s[1]-r[1]*s[0])
321    qp = q[0]-p[0],q[1]-p[1]
322
323    qpr = qp[0]*r[1]-qp[1]*r[0]
324    qps = qp[0]*s[1]-qp[1]*s[0]
325
326    if abs(rs)<1e-8:
327        if abs(qpr)<1e-8: return 'collinear'
328        return None
329
330    t = qps/rs
331    u = qpr/rs
332
333    if 0<=t<=1 and 0<=u<=1:
334        return p[0]+t*r[0], p[1]+t*r[1]
335
336def makeCircularString(x, y, radius, angle, text, fontName, fontSize, inside=0, G=None,textAnchor='start'):
337    '''make a group with circular text in it'''
338    if not G: G = Group()
339
340    angle %= 360
341    pi180 = pi/180
342    phi = angle*pi180
343    width = stringWidth(text, fontName, fontSize)
344    sig = inside and -1 or 1
345    hsig = sig*0.5
346    sig90 = sig*90
347
348    if textAnchor!='start':
349        if textAnchor=='middle':
350            phi += sig*(0.5*width)/radius
351        elif textAnchor=='end':
352            phi += sig*float(width)/radius
353        elif textAnchor=='numeric':
354            phi += sig*float(numericXShift(textAnchor,text,width,fontName,fontSize,None))/radius
355
356    for letter in text:
357        width = stringWidth(letter, fontName, fontSize)
358        beta = float(width)/radius
359        h = Group()
360        h.add(String(0, 0, letter, fontName=fontName,fontSize=fontSize,textAnchor="start"))
361        h.translate(x+cos(phi)*radius,y+sin(phi)*radius)    #translate to radius and angle
362        h.rotate((phi-hsig*beta)/pi180-sig90)               # rotate as needed
363        G.add(h)                                            #add to main group
364        phi -= sig*beta                                     #increment
365
366    return G
367
368class CustomDrawChanger:
369    '''
370    a class to simplify making changes at draw time
371    '''
372    def __init__(self):
373        self.store = None
374
375    def __call__(self,change,obj):
376        if change:
377            self.store = self._changer(obj)
378            assert isinstance(self.store,dict), '%s.changer should return a dict of changed attributes' % self.__class__.__name__
379        elif self.store is not None:
380            for a,v in self.store.items():
381                setattr(obj,a,v)
382            self.store = None
383
384    def _changer(self,obj):
385        '''
386        When implemented this method should return a dictionary of
387        original attribute values so that a future self(False,obj)
388        can restore them.
389        '''
390        raise RuntimeError('Abstract method _changer called')
391
392class FillPairedData(list):
393    def __init__(self,v,other=0):
394        list.__init__(self,v)
395        self.other = other
396