source: revtreeplugin/0.11/revtree/svgview.py

Last change on this file was 4847, checked in by Emmanuel Blot, 15 years ago

Fix up Tags management:

  • Proper positioning of the tag label when a branch is tagged multiple times
  • Do not show deleted tags
  • Property svn:eol-style set to native
File size: 38.2 KB
RevLine 
[1633]1# -*- coding: utf-8 -*-
2#
[3491]3# Copyright (C) 2006-2008 Emmanuel Blot <emmanuel.blot@free.fr>
[1633]4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution. The terms
8# are also available at http://trac.edgewall.com/license.html.
9#
10# This software consists of voluntary contributions made by many
11# individuals. For the exact contribution history, see the revision
12# history and logs, available at http://projects.edgewall.com/trac/.
13#
14
15import SVGdraw as SVG
16import os
[1697]17import md5
[1633]18
[1694]19from colorsys import rgb_to_hsv, hsv_to_rgb
[1633]20from math import sqrt
21from random import randrange, seed
[1908]22from revtree.api import *
[1694]23from trac.core import *
[2178]24from trac.web.href import Href
[1633]25
[1908]26__all__ = ['SvgColor', 'SvgGroup', 'SvgOperation', 'SvgRevtree']
27
[1633]28UNIT = 25
29SQRT2=sqrt(2)
30SQRT3=sqrt(3)
31
[1675]32# Debug functions to place debug circles on the SVG graph
[4024]33debugw = []
34def dbgPt(x,y,c='red',d=5):
35    debugw.append(SVG.circle(x,y,d, 'white', c, '2'))
36def dbgLn(x1,y1,x2,y2,c='red',w=3):
37    debugw.append(SVG.line(x1,y1,x2,y2,c,w))
38def dbgDump(svg):
39    map(svg.addElement, debugw)
[1633]40   
41def textwidth(text):
42    # kludge, this should get the actual font parameters, etc...
43    length = text and len(text) or 0
44    return (1+length)*(UNIT/2.5)
45
46class SvgColor(object):
[1675]47    """Helpers for color management (conversion, generation, ...)"""
[1633]48   
49    colormap = { 'black':       (0,0,0),
[1696]50                 'white':       (0xff,0xff,0xff),
[1633]51                 'darkred':     (0x7f,0,0),
52                 'darkgreen':   (0,0x7f,0),
53                 'darkblue':    (0,0,0x7f),
54                 'red':         (0xdf,0,0),
55                 'green':       (0,0xdf,0),
56                 'blue':        (0,0,0xdf),
57                 'gray':        (0x7f,0x7f,0x7f),
58                 'orange':      (0xff,0x9f,0) }
59   
[1697]60    def __init__(self, value=None, name=None):
[1633]61        if value is not None:
62            if isinstance(value, SvgColor):
63                self._color = value._color
64            elif isinstance(value, unicode):
65                self._color = SvgColor.str2col(value.encode('ascii'))
66            elif isinstance(value, str):
67                self._color = SvgColor.str2col(value)
68            elif isinstance(value, tuple):
69                if len(value) != 3:
70                    raise AssertionError, "invalid color values"
71                self._color = value
72            else:
73                raise AssertionError, "unsupportedcolor: %s" % value
[1697]74        elif name is not None:
75            self._color = SvgColor.from_name(name)
[1633]76        else:
77            self._color = SvgColor.random()
78           
79    def __str__(self):
80        return "#%02x%02x%02x" % self._color
81       
82    def rgb(self):
83        return "rgb(%d,%d,%d)" % self._color
84
85    def set(self, string):
86        self._color = SvgColor.str2col(string)
87       
88    def str2col(string):
[1908]89        if string.startswith('#'):
[1633]90            string = string[1:]
91            if len(string) == 6:
92                r = int(string[0:2], 16)
93                g = int(string[2:4], 16)
94                b = int(string[4:6], 16)
95                return (r,g,b)
96            elif len(string) == 3:
97                r = int(string[0:1], 16)*16
98                g = int(string[1:2], 16)*16
99                b = int(string[2:3], 16)*16
100                return (r,g,b)
101            else:
102                raise AssertionError, "invalid color"
103        else:
104            if SvgColor.colormap.has_key(string):
105                return SvgColor.colormap[string]
106            else:
107                raise AssertionError, "unknown color: %s" % string
108    str2col = staticmethod(str2col)
109
110    def random():
111        rand = "%03d" % randrange(1000)
112        return (128+14*int(rand[0]), 
113                128+14*int(rand[1]), 
114                128+14*int(rand[2]))
115    random = staticmethod(random)
116   
[1697]117    def from_name(name):
[2178]118        dig = md5.new(name.encode('utf-8')).digest()
[1697]119        vr = 14*(int(ord(dig[0]))%10)
120        vg = 14*(int(ord(dig[1]))%10)
121        vb = 14*(int(ord(dig[2]))%10)
122        return (128+vr, 128+vg, 128+vb)
123    from_name = staticmethod(from_name)
124   
[1633]125    def invert(self):
126        self._color = (0xff-self._color[0],
127                       0xff-self._color[1],
128                       0xff-self._color[2])
129
130    def strongify(self):
131        (r,g,b) = (float(self._color[0])/0xff, 
132                   float(self._color[1])/0xff, 
133                   float(self._color[2])/0xff)
134        (h,s,v) = rgb_to_hsv(r,g,b)
135        v /= 1.5;
136        s *= 3.0;
137        if s > 1: s = 1
138        (r,g,b) = hsv_to_rgb(h,s,v)
139        return SvgColor((int(r*0xff),int(g*0xff),int(b*0xff)))
140
[2361]141    def lighten(self):
142        (r,g,b) = (float(self._color[0])/0xff, 
143                   float(self._color[1])/0xff, 
144                   float(self._color[2])/0xff)
145        (h,s,v) = rgb_to_hsv(r,g,b)
146        v *= 1.5;
147        if v > 1: v = 1
148        (r,g,b) = hsv_to_rgb(h,s,v)
149        return SvgColor((int(r*0xff),int(g*0xff),int(b*0xff)))
[1633]150
[2361]151
[1633]152class SvgBaseChangeset(object):
[1675]153    """Base class for graphical changeset/revision nodes
154       This changeset cannot be rendered in the SVG graph"""
[1633]155   
156    def __init__(self, parent, revision, position=None):
157        self._parent = parent
158        self._revision = revision
159        self._position = position
160        self._htw = textwidth(str(self._revision))/2
161        self._radius = self._htw + UNIT/6
162        self._extent = (2*self._radius,2*self._radius)
163
164    def __cmp__(self, other):
165        return cmp(self._revision, other._revision)
166
167    def build(self):
168        if self._position is None:
169            self._position = self._parent.get_slot(self._revision)
170                           
171    def extent(self):
172        return self._extent
173       
174    def branch(self):
175        return self._parent
176       
177    def visible(self):
178        return False
179                             
180    def position(self, anchor=''):
181        (x,y) = self._position
182        fo = self._radius
183        ho = SQRT2*fo/2
184        h = len(anchor) > 1
185        if 'n' in anchor:
186            y -= (h and ho or fo);
187        if 's' in anchor:
188            y += (h and ho or fo);
189        if 'w' in anchor:
190            x -= (h and ho or fo);
191        if 'e' in anchor:
192            x += (h and ho or fo);
193        return (x,y)
194
195    def render(self):
196        pass
197
198
199class SvgChangeset(SvgBaseChangeset):
[1675]200    """Changeset/revision node"""
[1633]201   
202    def __init__(self, parent, changeset):
203        SvgBaseChangeset.__init__(self, parent, changeset.rev)
204        self._shape = 'circle'
205        self._enhance = False
[4847]206        self._tag_offset = 0
[1633]207        self._fillcolor = self._parent.fillcolor()
208        self._strokecolor = self._parent.strokecolor()
209        self._textcolor = SvgColor('black')
[1696]210        self._classes = ['svgchangeset']
[1633]211       
212    def set_shape(self, shape):
[1696]213        """Define the shape of the svg changeset [circle,square,hexa].
214           If the first letter is uppercase, the shape is augmented with
215           fancy lines.
216        """
[1633]217        self._shape = shape.lower()
218        self._enhance = shape[0] != self._shape[0]
219       
[1696]220    def mark_first(self):
221        """Marks the changeset as the first of the branch.
222           Inverts the background and the foreground color"""
223        self._classes.append('firstchangeset')
[1633]224       
[1696]225    def mark_last(self):
226        """Mark the changeset as the latest of the branch"""
227        self._classes.append('lastchangeset')
[1633]228
229    def build(self):
230        SvgBaseChangeset.build(self)
[2272]231        (fgc, bgc) = (self._strokecolor, self._fillcolor)
232        txc = self._textcolor
233        if 'firstchangeset' in self._classes:
234            (fgc, bgc) = (bgc, fgc)
235        if 'lastchangeset' in self._classes:
236            bgc = SvgColor('black')
237            txc = SvgColor('white')
238           
[1696]239        widgets = []
[1633]240        if self._shape == 'circle':
[1696]241            widgets.append(SVG.circle(self._position[0], self._position[1],
[2272]242                                      self._radius, bgc, fgc,
[1696]243                                      self._parent.strokewidth()))
[1633]244            if self._enhance:
245                (x,y) = self._position
246                (d,hr) = (self._radius*SQRT3/2, self._radius/2)
[1696]247                widgets.append(SVG.line(x-d,y-hr,x+d,y-hr, 
[2272]248                                        fgc, self._parent.strokewidth()))
[1696]249                widgets.append(SVG.line(x-d,y+hr,x+d,y+hr, 
[2272]250                                        fgc, self._parent.strokewidth()))
[1633]251                             
252        elif self._shape == 'square':
[1696]253            r = UNIT/6
254            size = self._radius-r
255            widgets.append(SVG.rect(self._position[0]-size, 
[1633]256                                    self._position[1]-size,
[2272]257                                    2*size, 2*size, bgc, fgc,
[1696]258                                    self._parent.strokewidth()))
259            outline.attributes['rx'] = r
260            outline.attributes['ry'] = r       
261           
[1633]262        elif self._shape == 'hexa':
263            (x,y) = self._position
264            (r,hr) = (self._radius, self._radius/2)
265            pd = SVG.pathdata()
266            pd.move(x,y-r)
267            pd.line(x+r,y-hr)
268            pd.line(x+r,y+hr)
269            pd.line(x,y+r)
270            pd.line(x-r,y+hr)
271            pd.line(x-r,y-hr)
272            pd.line(x,y-r)
[2272]273            widgets.append(SVG.path(pd, bgc, fgc, 
[1696]274                                    self._parent.strokewidth()))
[1633]275        else:
276            raise AssertionError, \
277                  "unsupported changeset shape (%d)" % self._revision
[1707]278        title = SVG.text(self._position[0], 
[1696]279                         self._position[1] + UNIT/6,
[1707]280                         str(self._revision), 
281                         self._parent.fontsize(), self._parent.fontname())
[2272]282        title.attributes['style'] = 'fill:%s; text-anchor: middle' % txc.rgb()
[1696]283        widgets.append(title)
284        g = SVG.group('grp%d' % self._revision, elements=widgets)
[1911]285        link = "%s/changeset/%d" % (self._parent.urlbase(), self._revision)
[1696]286        self._link = SVG.link(link, elements=[g])
[1633]287        if self._revision:
[2272]288            self._link.attributes['style'] = \
289                'color: %s; background-color: %s' % \
290                    (self._strokecolor, self._fillcolor)
[1633]291            self._link.attributes['id'] = 'rev%d' % self._revision
[1696]292            self._link.attributes['class'] = ' '.join(self._classes)
[1633]293                   
[4847]294    def tag_offset(self, height):
295        offset = self._tag_offset
296        self._tag_offset += height
297        return offset
298
[4024]299    def strokewidth(self):
300        return self._parent.strokewidth()
301
302    def strokecolor(self):
303        return self._parent.strokecolor()
304   
305    def fillcolor(self):
306        return self._parent.fillcolor()
307       
308    def fontsize(self):
309        return self._parent.fontsize()
310       
311    def fontname(self):
312        return self._parent.fontname()
313
314    def urlbase(self):
315        return self._parent.urlbase()
316       
[1633]317    def visible(self):
318        return True
319               
320    def render(self):
321        self._parent.svg().addElement(self._link)
322
323
324class SvgBranchHeader(object):
[1675]325    """Branch title"""
[1633]326   
[4024]327    def __init__(self, parent, path, title, lastrev):
[1633]328        self._parent = parent
[1707]329        self._title = title or ''
[4024]330        self._path = path
331        self._rev = lastrev
[1633]332        self._tw = textwidth(self._title)+UNIT/2
333        self._w = max(self._tw, 6*UNIT)
334        self._h = 2*UNIT
335       
336    def position(self, anchor=''):
337        (x,y) = (self._position[0]+self._w/2, self._position[1]) 
338        if 'n' in anchor:
339            pass;
340        if 's' in anchor:
341            y += self._h;
342        if 'w' in anchor:
343            pass;
344        if 'e' in anchor:
345            x += self._w;
346        return (x,y)
347       
348    def extent(self):
349        return (self._w, self._h)
350       
351    def build(self):
352        self._position = self._parent.position()
353        x = self._position[0]+(self._w-self._tw)/2
354        y = self._position[1]
355        r = UNIT/2
[1707]356        rect = SVG.rect(x,y,self._tw,self._h,
357                        self._parent.fillcolor(), 
358                        self._parent.strokecolor(), 
359                        self._parent.strokewidth())
360        rect.attributes['rx'] = r
361        rect.attributes['ry'] = r       
362        text = SVG.text(self._position[0]++self._w/2, 
363                        self._position[1]+self._h/2+UNIT/6,
[4024]364                        self._title.encode('utf-8'), 
[1707]365                        self._parent.fontsize(), self._parent.fontname())
366        text.attributes['style'] = 'text-anchor: middle'
[2178]367        name = self._title.encode('utf-8').replace('/','')
[1707]368        g = SVG.group('grp%s' % name, elements=[rect, text])
[2178]369        href = Href(self._parent.urlbase())
[4024]370        self._link = SVG.link(href.browser(self._path, rev='%d' % self._rev), 
371                              elements=[g])
[1633]372       
373    def render(self):
374        self._parent.svg().addElement(self._link)
375       
376
[4024]377class SvgTag(object):
378    """Graphical view of a tag"""
379   
380    def __init__(self, parent, path, title, rev, src):
381        self._parent = parent
382        self._title = title or ''
383        self._path = path
384        self._revision = rev
385        self._srcchgset = src
386        self._tw = textwidth(self._title)+UNIT/2
387        self._w = self._tw
388        self._h = 1.2*UNIT
389        self._opacity = 75
390       
391    def position(self, anchor=''):
392        (x,y) = (self._position[0]+self._w/2, self._position[1]) 
393        if 'n' in anchor:
394            pass;
395        if 's' in anchor:
396            y += self._h;
397        if 'w' in anchor:
398            pass;
399        if 'e' in anchor:
400            x += self._w;
401        return (x,y)
402       
403    def extent(self):
404        return (self._w, self._h)
405       
[4847]406    def build(self):
[4024]407        (sx, sy) = self._srcchgset.position()
[4847]408        h_offset = self._srcchgset.tag_offset(self._h)
[4024]409        self._position = (sx + (self._srcchgset.extent()[0])/2,
410                          sy - (3*self._h)/2 + h_offset)
411        x = self._position[0]+(self._w-self._tw)/2
412        y = self._position[1]
413        r = UNIT/2
414        rect = SVG.rect(x,y,self._tw,self._h,
415                        self._srcchgset.strokecolor(),
416                        self._srcchgset.fillcolor(), 
417                        self._srcchgset.strokewidth())
418        rect.attributes['rx'] = r
419        rect.attributes['ry'] = r       
420        rect.attributes['opacity'] = str(self._opacity/100.0) 
421        text = SVG.text(self._position[0]+self._w/2, 
422                        self._position[1]+self._h/2+UNIT/4,
423                        "%s" % self._title.encode('utf-8'), 
424                        self._srcchgset.fontsize(), 
425                        self._srcchgset.fontname())
426        txc = SvgColor('white')
427        text.attributes['style'] = 'fill:%s; text-anchor: middle' % txc.rgb()
428        name = self._title.encode('utf-8').replace('/','')
429        g = SVG.group('grp%d' % self._revision, elements=[rect, text])
430        link = "%s/changeset/%d" % (self._parent.urlbase(), self._revision)
431        self._link = SVG.link(link, elements=[g])
432        self._link.attributes['id'] = 'rev%d' % self._revision
433        self._link.attributes['style'] = \
434            'color: %s; background-color: %s' % \
435                (self._srcchgset.fillcolor(), self._srcchgset.strokecolor())
436       
437    def render(self):
438        self._parent.svg().addElement(self._link)
439
440
[1633]441class SvgBranch(object):
[1675]442    """Branch (set of changesets which whose commits share a common base
443       directory)"""
444       
[1652]445    def __init__(self, parent, branch, style):
[1633]446        self._parent = parent
447        self._branch = branch
448        self._svgchangesets = {}
[4024]449        self._svgtags = {}
450        self._svgwidgets = [[] for l in IRevtreeEnhancer.ZLEVELS]
[1633]451        self._maxchgextent = [0,0]
452        self._fillcolor = self._get_color(branch.name, parent.trunks)
453        self._strokecolor = self._fillcolor.strongify()
454        self._source = branch.source()
455        try:
[1652]456            self.get_slot = self.__getattribute__('get_%s_slot' % style)
[1633]457        except AttributeError:
[1652]458            raise AssertionError, "Unsupported branch style: %s" % style
[1633]459        pw = None
460        transitions = []
461        changesets = branch.changesets(parent.revrange);
462        changesets.sort()
463        changesets.reverse()
[4024]464        if changesets[0].last:
465            # it would require parsing the history another time to find
466            # the previous changeset when it is not in the specified range
467            lastrev = changesets[len(changesets) > 1 and 1 or 0].rev
468        else:
469            lastrev = changesets[0].rev
470        self._svgheader = \
471            SvgBranchHeader(self, branch.name, branch.prettyname, lastrev)
[1633]472        for c in changesets:
473            svgc = SvgChangeset(self, c)
474            self._update_chg_extent(svgc.extent())
475            if pw is None:
476                transitions.append(SvgAxis(self, self._svgheader, svgc))
477            else:
478                transitions.append(SvgTransition(self, pw, svgc, 'gray'))
479            self._svgchangesets[c] = svgc
[4024]480            self._svgwidgets[IRevtreeEnhancer.ZMID].append(svgc)
[1633]481            pw = svgc
482        svgc = SvgBaseChangeset(self, 0)
483        self._update_chg_extent(svgc.extent())
484        self._svgchangesets[0] = svgc
[4024]485        self._svgwidgets[IRevtreeEnhancer.ZMID].append(svgc)
486        self._svgwidgets[IRevtreeEnhancer.ZMID].extend(transitions)
[1633]487       
488    def __cmp__(self, other):
489        xs = self._position[0]+self._extent[0]
490        os = other._position[0]+other._extent[0]
491        return cmp(xs,os)
492       
493    def _update_chg_extent(self, extent):
494        if self._maxchgextent[0] < extent[0]:
495            self._maxchgextent[0] = extent[0]
496        if self._maxchgextent[1] < extent[1]:
497            self._maxchgextent[1] = extent[1]
498           
499    def _get_color(self, name, trunks):
[1697]500        """Creates a random pastel color based on the branch name
501        or returns a predefined color if the branch is a trunk"""
[1633]502        if name in trunks:
503            return SvgColor(self._parent.env.config.get('revtree', 
504                                                        'trunkcolor', 
505                                                        '#cfcfcf'))
506        else:
[1697]507            return SvgColor(name=name)
[4024]508
509    def create_tag(self, tag):
510        svgcs = self.svgchangeset(tag.source())
511        self._svgwidgets[IRevtreeEnhancer.ZFORE].append(\
512            SvgTag(self, tag.name, tag.prettyname, tag.rev, svgcs))
[1633]513                     
514    def build(self, position):
515        self._position = position
516        self._slot = self._slotgen()
517        self._svgheader.build()
518        (w, h) = self._svgheader.extent()
[4024]519        for wl in self._svgwidgets:
520            for wdgt in wl:
521                if not isinstance(wdgt, SvgTag):
522                    wdgt.build()
523                    h += wdgt.extent()[1]
[4847]524                else: 
525                    wdgt.build()
[4024]526                    (tw, th) = wdgt.extent()
527                    nw = tw/2 + wdgt.position()[0]-position[0]
528                    if nw > w: w = nw
[1633]529        self._extent = (w, h)
530           
531    def svgarrow(self, color, head):
532        return self._parent.svgarrow(color, head)
533   
534    def header(self):
535        return self._svgheader
536       
537    def svgchangesets(self):
538        return self._svgchangesets.values()
539               
540    def svgchangeset(self, changeset):
541        if self._svgchangesets.has_key(changeset):
542            return self._svgchangesets[changeset]
543        return None
544       
545    def branch(self):
546        return self._branch
547       
548    def position(self):
549        return self._position
550       
551    def extent(self):
552        return self._extent
553       
554    def get_compact_slot(self, revision):
555        return self._slot.next()
556   
557    def get_timeline_slot(self, revision):
558        x = self.vaxis()
559        if revision != 0:
560            y = self._parent.chgoffset(revision)
561            y = (2+y)*2*self._maxchgextent[1]
562        else:
563            changesets = []
564            for (k,v) in self._svgchangesets.items():
565                if v._revision != 0:
566                    changesets.append(k)
567            changesets.sort()
568            oldest = changesets[0]
569            y = self._svgchangesets[oldest].position()[1]
570            y += 2*self._maxchgextent[1]
571        return (x,y)
[4024]572           
[1633]573    def strokewidth(self):
574        return self._parent.strokewidth()
575
576    def strokecolor(self):
577        return self._strokecolor
578   
579    def fillcolor(self):
580        return self._fillcolor
581       
[1707]582    def fontsize(self):
583        return self._parent.fontsize
584       
585    def fontname(self):
586        return self._parent.fontname
587
[1633]588    def urlbase(self):
[4024]589        return self._parent.urlbase()
[1633]590       
591    def _slotgen(self):
592        x = self._position[0] + self._svgheader.extent()[0]/2
593        y = self._position[1] + self._svgheader.extent()[1] + \
594            2*self._maxchgextent[1]
595        while True:
596            yield (x,y)
597            y += 2*self._maxchgextent[1]
598           
599    def vaxis(self):
600        """Return the position of the vertical axis"""
601        return self._position[0] + self._svgheader.extent()[0]/2
602           
603    def svg(self):
604        return self._parent.svg()
605
[4024]606    def render(self, level=None):
[1633]607        self._svgheader.render()
[4024]608        if level:
609            map(lambda w: w.render(), self._svgwidgets[level])
610        else:
611            for wl in self._svgwidgets:
612                map(lambda w: w.render(), wl)
[1633]613
614
615class SvgAxis(object):
[1675]616    """Simple graphical line between a header and the youngest
617       revision of a branch"""
618       
[1633]619    def __init__(self, parent, head, tail, color='#7f7f7f'):
620        self._parent = parent
621        self._head = head
622        self._tail = tail
623        self._color = SvgColor(color)
624
625    def build(self):
626        sp = self._head.position('s')
627        dp = self._tail.position('n')
628        self._extent = (abs(dp[0]-sp[0]),abs(dp[1]-sp[1]))
629        self._widget = SVG.line(sp[0], sp[1], dp[0], dp[1], self._color, 
630                                self._parent.strokewidth())
631        self._widget.attributes['stroke-dasharray']='4,4'
632
633    def extent(self):
634        return self._extent
635
636    def render(self):
637        self._parent.svg().addElement(self._widget)
638
639
640class SvgTransition(object):
[1675]641    """Simple graphical line between two consecutive changesets
642       on the same branch"""
643       
[1633]644    def __init__(self, parent, srcChg, dstChg, color):
645        self._parent = parent
646        self._source = srcChg
647        self._dest = dstChg
648        self._color = color
649
650    def build(self):
651        sp = self._dest.position('n')
652        dp = self._source.position('s')
653        self._extent = (abs(dp[0]-sp[0]),abs(dp[1]-sp[1]))
654        self._widget = SVG.line(sp[0], sp[1], dp[0], dp[1], self._color, 
655                                self._parent.strokewidth())
656        self._widget.attributes['marker-end'] = \
657            self._parent.svgarrow(self._color, False)
658
659    def extent(self):
660        return self._extent
661
662    def render(self):
663        self._parent.svg().addElement(self._widget)
664
665
666class SvgGroup(object):
[1675]667    """Graphical group of consecutive changesets within a same branch"""
[1633]668   
[2361]669    def __init__(self, parent, firstChg, lastChg, 
670                 color='#fffbdb', opacity=50):
[1633]671        self._parent = parent
672        self._first = firstChg
673        self._last  = lastChg
674        self._fillcolor = SvgColor(color)
675        self._strokecolor = self._fillcolor.strongify()
[2361]676        self._opacity = opacity
[1633]677   
678    def build(self):
679        spos = self._first.position()[1]
680        epos = self._last.position()[1]
681        if spos > epos:
682            (self._first, self._last) = (self._last, self._first)
683        sp = self._first.position('n')
684        ep = self._last.position('s')
685        r = UNIT/2
686        w = self._first.extent()[0] + UNIT
687        h = ep[1] - sp[1] + UNIT
688        x = sp[0] - w/2
689        y = sp[1] - UNIT/2
690        self._widget = SVG.rect(x,y,w,h,
691                                self._fillcolor, 
692                                self._strokecolor, 
693                                self._parent.strokewidth())
694        self._widget.attributes['rx'] = r
695        self._widget.attributes['ry'] = r
[2361]696        self._widget.attributes['opacity'] = str(self._opacity/100.0) 
[1633]697        self._extent = (w,h)
698       
699    def extent(self):
700        return self._extent
701
702    def render(self):
703        self._parent.svg().addElement(self._widget)
704       
705       
706class SvgOperation(object):
[1675]707    """Graphical operation between two changesets of distinct branches
708       (such as a switch/branch creation, a merge operation, ...)"""
709       
[1696]710    def __init__(self, parent, srcChg, dstChg, color='black', classes=[]):
[1633]711        self._parent = parent
712        self._source = srcChg
713        self._dest = dstChg
714        self._color = SvgColor(color)
[1696]715        self._classes = classes
[1633]716
717    def build(self):
718        if self._source.branch() == self._dest.branch():
719            self._widget = None
720            self._parent.env.log.warn("Invalid operation")
721            return 
722        # get the position of the changeset to tie
723        (xs,ys) = self._source.position()
724        (xe,ye) = self._dest.position()
725        # swap start and end points so that xs < xe
726        if xs > xe:
727            head = True
728            (self._source, self._dest) = (self._dest, self._source)
729            (xs,ys) = self._source.position()
730            (xe,ye) = self._dest.position()
731        else:
732            head = False
733        xbranches = self._parent.xsvgbranches(self._source, self._dest)       
734        # find which points on the changeset widget are used for connections
735        if xs < xe:
736            ss = 'e'
737            se = 'w'
738        else:
739            ss = 'w'
740            se = 'e'
741        ps = self._source.position(ss)
742        pe = self._dest.position(se)
743        # compute the straight line from start to end widgets
744        a = (ye-ys)/(xe-xs)
745        b = ys-(a*xs)
746        bz = []
747        # compute the points through which the 'operation' curve should go
748        (xct,yct) = (ps[0],ps[1])
749        points = [(xct,yct)]
750        for br in xbranches:
751            x = br.vaxis()
752            y = (a*x)+b
753            ycu = ycd = None
754            schangesets = br.svgchangesets()
755            schangesets.sort()
756            # add an invisible changeset in place of the branch header to avoid
757            # special case for the first changeset
758            hpos = br.header().position()
759            hchg = SvgBaseChangeset(br, 0, (hpos[0], hpos[1]+3*UNIT/2))
760            schangesets.append(hchg)
761            schangesets.reverse()
762            pc = None
763            for c in schangesets:
764                # find the changesets which are right above and under the
765                # selected point, and store their vertical position
766                yc = c.position()[1]
767                if yc < y:
768                    ycu = yc
769                if yc >= y:
770                    ycd = yc
771                    if not ycu:
772                        if pc:
773                            ycu = pc.position()[1]
774                        elif c != schangesets[-1]:
775                            ycu = schangesets[-1].position()[1]
776                    break
777                pc = c
778            if not ycu or not ycd:
779                pass
780                # in this case, we need to create a virtual point (TODO)
781            else:
782                xt = x
783                yt = (ycu+ycd)/2
784                if a != 0:
785                    a2 = -1/a
786                    b2 = yt - a2*xt
787                    xl = (b2-b)/(a-a2)
788                    yl = a2*xl + b2
789                    nx = xt-xl
790                    ny = yt-yl
791                    dist = sqrt(nx*nx+ny*ny)
792                    radius = (3*c.extent()[1])/2
793                    add_point = dist < radius
794                else:
795                    add_point = True
796                # do not insert a point if the ideal curve is far enough from
797                # an existing changeset
798                if add_point:
799                    # update the vertical position for the bezier control
800                    # point with the point that stands between both closest
801                    # changesets
802                    (xt,yt) = self._parent.fixup_point((xt,yt))
803                    points.append((xt,yt))
804        if head:
805            points.append(pe)
806        else:
807            points.append((pe[0]-UNIT,pe[1]))
808        # now compute the qbezier curve
809        pd = SVG.pathdata()
810        pd.move(points[0][0],points[0][1])
811        if head:
812            pd.line(points[0][0]+UNIT,points[0][1])
813        for i in range(len(points)-1):
814            (xl,yl) = points[i]
815            (xr,yr) = points[i+1]
816            (xi,yi) = ((xl+xr)/2,(yl+yr)/2)
817            pd.qbezier(xl+2*UNIT,yl,xi,yi)
818            pd.qbezier(xr-2*UNIT,yr,xr,yr)
819        if not head:
820            pd.line(pe[0],pe[1])
821        self._widget = SVG.path(pd, 'none', self._color, 
822                                self._parent.strokewidth())
823        self._widget.attributes['marker-%s' % (head and 'start' or 'end') ] = \
824            self._parent.svgarrow(self._color, head)
[1696]825        if self._classes:
826            self._widget.attributes['class'] = ' '.join(self._classes)
[1633]827
828    def extent(self):
829        return self._extent
830
831    def render(self):
832        if self._widget:
833            self._parent.svg().addElement(self._widget)
834       
835
836class SvgArrows(object):
[1675]837    """Arrow headers for graphical links and operations"""
[1633]838   
839    def __init__(self, parent):
840        self._parent = parent
841        self._markers = {}
842       
843    def _get_name(self, color, head):
[1911]844        fcolor = str(color)
845        if fcolor.startswith('#'):
846            fcolor = fcolor[1:]
847        return 'arrow_%s_%s' % (head and 'head' or 'tail', fcolor)
[1633]848       
849    def create(self, color, head):
850        name = self._get_name(color, head)
851        if not self._markers.has_key(name):
[3580]852            # It seems that WebKit needs some adjustements ...
853            # xos = (3.0*UNIT/100)
854            # yos = (3.0*UNIT/100)
855            # ... but Gecko does not
856            xos = 0
857            yos = 0
[1633]858            if head:
859                marker = SVG.marker(name, (0,0,10,8), 0, 4, UNIT/4, UNIT/4,
860                                    fill=SvgColor(color), orient='auto')
[3580]861                marker.addElement(SVG.polyline(((0-xos,4-yos),(10-xos,0-yos),
862                                                (10-xos,8-yos),(0-xos,4-yos))))
[1633]863            else:
864                marker = SVG.marker(name, (0,0,10,8), 10, 4, UNIT/4, UNIT/4,
865                                    fill=SvgColor(color), orient='auto')
[3580]866                marker.addElement(SVG.polyline(((0+xos,0-yos),(0+xos,8-yos),
867                                                (10+xos,4-yos),(0+xos,0-yos))))
[1633]868            self._markers[name] = marker
869        return name
870       
871    def build(self):
872        pass
873       
874    def render(self):
875        map(self._parent.svg().addElement, self._markers.values())
876
877   
878class SvgRevtree(object):
[1675]879    """Main object that represents the revision tree as a SVG graph"""
880
[1694]881    def __init__(self, env, repos, urlbase, enhancers, optimizer):
[1633]882        """Construct a new SVG revision tree"""
883        # Environment
884        self.env = env
885        # URL base of the repository
[4024]886        self.url_base = urlbase
[1633]887        # Repository instance
888        self.repos = repos
889        # Range of revision to process
890        self.revrange = None
891        # Optional enhancers
[1694]892        self.enhancers = enhancers
893        # Optimizer
894        self.optimizer = optimizer
[1633]895        # Trunk branches
[1652]896        self.trunks = self.env.config.get('revtree', 'trunks', 
897                                          'trunk').split(' ')
[1707]898        # FIXME: Use CSS properties instead - when browsers support them...
899        # Font name
900        self.fontname = self.env.config.get('revtree', 'fontname', 'arial')
901        # Font size
902        self.fontsize = self.env.config.get('revtree', 'fontsize', '14pt')
[4024]903        # Dictionary of branch widgets (branches as keys)
[1633]904        self._svgbranches = {}
905        # Markers
906        self._arrows = SvgArrows(self)
907        # List of inter branch operations
908        self._svgoperations = []
909        # List of changeset groups
910        self._svggroups = []
911        # Operation points
912        self._oppoints = {}
[1694]913        # Add-on elements (from enhancers)
[4024]914        self._addons = []
[1633]915        # Init color generator with a predefined value
916        seed(0)
[1694]917               
[1633]918    def position(self):
919        """Return the position of the revision tree widget"""
920        return (UNIT,2*UNIT)
921       
922    def extent(self):
923        """Return the extent of the revtree"""
924        return (int(self._extent[0]),int(self._extent[1]))
925       
926    def strokewidth(self):
927        """Return the width of a stroke"""
928        return 3
929       
930    def svgbranch(self, rev=None, branchname=None, branch=None):
931        """Return a branch widget, based on the revision number or the
932           branch id"""
933        if not branch:
934            if rev:
935                chg = self.repos.changeset(rev)
[4024]936                if not chg:
937                    self.env.log.warn("No changeset %d" % rev)
938                    return None
939                self.env.log.info("Changeset %d, branch %s" % (rev, chg.branchname))
[1633]940                branch = self.repos.branch(chg.branchname)
941            elif branchname:
942                branch = self.repos.branch(branchname)
943        if not branch: 
944            return None
945        if not self._svgbranches.has_key(branch):
946            return None
947        return self._svgbranches[branch]
948       
949    def svgbranches(self):
950        return self._svgbranches
951       
952    def svgarrow(self, color, head):
953        return 'url(#%s)' % self._arrows.create(color, head)
954       
[1694]955    def create(self, req, revisions=None, branches=None, authors=None, 
[1652]956                     hidetermbranch=False, style='compact'):
[1633]957        if revisions is not None:
958            self.revrange = revisions
959        else:
960            self.revrange = self.repos.revision_range()
961        if hidetermbranch:
962            allbranches = filter(lambda b: b.is_active(self.revrange), 
963                                 self.repos.branches().values())
964        else:
965            allbranches = self.repos.branches().values()
[2497]966        revisions = []
[1633]967        for b in allbranches:
968            if branches:
969                if b.name not in branches:
970                    continue
971            if authors:
972                if not [a for a in authors for x in b.authors() if a == x]:
973                    continue
[1652]974            svgbranch = SvgBranch(self, b, style)
[1633]975            self._svgbranches[b] = svgbranch
976            revisions.extend([c.rev for c in b.changesets()])
977        revisions.sort()
978        revisions.reverse()
979        self._vtimes = {}
980        vtime = 0
981        for r in revisions:
982            self._vtimes[r] = vtime
983            vtime += 1
[1694]984        for enhancer in self.enhancers:
[4024]985            self._addons.append(enhancer.create(self.env, req, 
986                                                self.repos, self))
987        for tag in self.repos.tags().values():
988            self.env.log.info("Found tag: %r" % tag.name)
989            if tag.clone:
990                svgbr = self.svgbranch(rev=tag.clone[0])
991                if svgbr:
992                    svgbr.create_tag(tag)
[1633]993                     
994    def build(self):
995        """Build the graph"""
[1694]996        branches = self.optimizer.optimize(self.repos, \
997            [svgbr.branch() for svgbr in self._svgbranches.values()])
[1633]998        branch_xpos = UNIT
999        svgbranches = [self.svgbranch(branch=b) for b in branches]
1000        for svgbranch in svgbranches:
1001            svgbranch.build((branch_xpos, UNIT/6))
[4024]1002            #branch_xpos += svgbranch.header().extent()[0] + UNIT
1003            branch_xpos += svgbranch.extent()[0] + UNIT
1004        # TODO: discard tags for which source changeset do not exist
1005        #for svgtag in self.svgtags().values():
1006        #    self.env.log.info("Build Tag %s" % svgtag)
1007        #    svgtag.build()
1008        map(lambda e: e.build(), self._addons)
[1694]1009        # FIXME: why not using svgbranches ?
[1633]1010        svgbranches = self._svgbranches.values()
1011        svgbranches.sort()
1012        maxheight = 0
1013        for b in svgbranches:
1014            h = b.extent()[1]
1015            if h > maxheight:
1016                maxheight = h
1017        if not svgbranches:
[1694]1018            raise EmptyRangeError
[1633]1019        w = svgbranches[-1].position()[0] - svgbranches[0].position()[0] + \
1020            svgbranches[-1].extent()[0] + 2*UNIT
1021        maxheight += UNIT
1022        self._extent = (w,maxheight)
[4024]1023           
1024    def urlbase(self):
1025        return self.url_base
1026
[1633]1027    def chgoffset(self, revision):
1028        return self._vtimes[revision]
1029       
1030    def xsvgbranches(self, c1, c2):
1031        """Provide the ordered list of branch widgets which are
1032        between two changeset wdigets"""
1033        a1 = c1.branch().vaxis()
1034        a2 = c2.branch().vaxis()
1035        branches = filter(lambda b,l=a1,r=a2: l<b.vaxis()<r, 
1036                          self._svgbranches.values())
1037        branches.sort() 
1038        return branches
1039       
1040    def fixup_point(self, point):
1041        """Avoid two operation path to go through the same point
1042           Store every points of an operation path. If the point is already
1043           marked as used, find another point, looking around the original
[1675]1044           point for a free slot"""
[1633]1045        (x, y) = point
1046        kx = int(x)
1047        if self._oppoints.has_key(kx):
1048            val = 1
1049            inc = 1
1050            while y in self._oppoints[kx]:
1051                y += val*(UNIT/3)
1052                val = -val + inc
1053                inc = -inc
1054        else:
1055            self._oppoints[kx] = []
1056        self._oppoints[kx].append(y)
1057        return (x, y)
1058       
1059    def svg(self):
1060        return self._svg
[1666]1061
1062    def __str__(self):
[2178]1063        """Dump the revision tree as a SVG UTF-8 string"""
[1666]1064        import cStringIO
1065        xml=cStringIO.StringIO()
1066        self._svg.toXml(0, xml)
1067        return xml.getvalue()
[1633]1068       
[1666]1069    def save(self, filename):
1070        """Save the revision tree in a file"""
1071        d = SVG.drawing()
1072        d.setSVG(self._svg)
1073        d.toXml(filename)
1074       
[2832]1075    def render(self, scale=1.0):
[1694]1076        """Render the revision tree"""
[2832]1077        self._svg = SVG.svg((0, 0, self._extent[0], self._extent[1]),
[1911]1078                            scale*self._extent[0], scale*self._extent[1],
[3491]1079                            True, id='svgbox')
[1633]1080        self._arrows.render()
[4024]1081        map(lambda e: e.render(IRevtreeEnhancer.ZBACK), self._addons)
1082        map(lambda b: b.render(IRevtreeEnhancer.ZBACK), 
1083                               self._svgbranches.values())
1084        map(lambda e: e.render(IRevtreeEnhancer.ZMID), self._addons)
1085        map(lambda b: b.render(IRevtreeEnhancer.ZMID), 
1086                               self._svgbranches.values())
1087        map(lambda e: e.render(IRevtreeEnhancer.ZFORE), self._addons)
1088        map(lambda b: b.render(IRevtreeEnhancer.ZFORE), 
1089                               self._svgbranches.values())
1090        dbgDump(self._svg)
Note: See TracBrowser for help on using the repository browser.