source: branches/Release-1-fixes/python/asaplot.py @ 618

Last change on this file since 618 was 618, checked in by mar637, 19 years ago
  • Fixes for multipaneling labelling and layout
  • reworked set_limits.
  • support auto location for legend in matplotlib 0.8
  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
File size: 19.7 KB
RevLine 
[111]1"""
2ASAP plotting class based on matplotlib.
3"""
4
5import sys
6from re import match
7import Tkinter as Tk
8
9import matplotlib
10matplotlib.use("TkAgg")
11
12from matplotlib.backends import new_figure_manager, show
13from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, \
[117]14        FigureManagerTkAgg
[111]15from matplotlib.figure import Figure, Text
[491]16from matplotlib.font_manager import FontProperties
[118]17from matplotlib.numerix import sqrt
[482]18from matplotlib import rc, rcParams
[111]19
20# Force use of the newfangled toolbar.
21matplotlib.rcParams['toolbar'] = 'toolbar2'
22
23# Colour dictionary.
24colours = {}
25
26class ASAPlot:
27    """
28    ASAP plotting class based on matplotlib.
29    """
30
[482]31    def __init__(self, rows=1, cols=0, title='', size=(8,6), buffering=False):
[117]32        """
33        Create a new instance of the ASAPlot plotting class.
[119]34
35        If rows < 1 then a separate call to set_panels() is required to define
36        the panel layout; refer to the doctext for set_panels().
[117]37        """
38        self.window = Tk.Tk()
[482]39        self.is_dead = False
40        def dest_callback():
41            self.is_dead = True
42            self.window.destroy()
[111]43
[482]44        self.window.protocol("WM_DELETE_WINDOW", dest_callback)
45
46        self.figure = Figure(figsize=size, facecolor='#ddddee')
[202]47        self.canvas = FigureCanvasTkAgg(self.figure, master=self.window)
[117]48        self.canvas.get_tk_widget().pack(side=Tk.TOP, fill=Tk.BOTH, expand=1)
[111]49
[117]50        # Simply instantiating this is enough to get a working toolbar.
51        self.figmgr = FigureManagerTkAgg(self.canvas, 1, self.window)
52        self.window.wm_title('ASAPlot graphics window')
[111]53
[120]54        self.events = {'button_press':None,
55                       'button_release':None,
56                       'motion_notify':None}
57
[118]58        self.set_title(title)
[117]59        self.subplots = []
[118]60        if rows > 0:
61            self.set_panels(rows, cols)
[111]62
63
[117]64        # Set matplotlib default colour sequence.
[202]65        self.colours = [1, 'b', 'g', 'r', 'c', 'm', 'y', 'k']
[117]66        self.attributes = {}
[618]67        self.loc = 0
[111]68
[117]69        matplotlib.interactive = True
70        self.buffering = buffering
[482]71
[117]72        self.canvas.show()
[111]73
74
[117]75    def clear(self):
76        """
77        Delete all lines from the plot.  Line numbering will restart from 1.
78        """
[111]79
[117]80        for i in range(1,len(self.lines)+1):
81           self.delete(i)
82        self.axes.clear()
83        self.colours[0] = 1
84        self.lines = []
[111]85
[482]86
[111]87    def delete(self, numbers=None):
[117]88        """
89        Delete the 0-relative line number, default is to delete the last.
90        The remaining lines are NOT renumbered.
91        """
[111]92
[117]93        if numbers is None: numbers = [len(self.lines)-1]
[111]94
[117]95        if not hasattr(numbers, '__iter__'):
96            numbers = [numbers]
[111]97
[117]98        for number in numbers:
99            if 0 <= number < len(self.lines):
100                if self.lines[number] is not None:
101                    for line in self.lines[number]:
102                        line.set_linestyle('None')
103                        self.lines[number] = None
[618]104        self.show()       
[111]105
106    def get_line(self):
[117]107        """
108        Get the current default line attributes.
109        """
110        return self.attributes
[111]111
112
[119]113    def hist(self, x=None, y=None, fmt=None):
114        """
115        Plot a histogram.  N.B. the x values refer to the start of the
116        histogram bin.
117
118        fmt is the line style as in plot().
119        """
120
121        if x is None:
122            if y is None: return
123            x = range(0,len(y))
124
125        if len(x) != len(y):
126            return
127
128        l2 = 2*len(x)
129        x2 = range(0,l2)
130        y2 = range(0,l2)
131
132        for i in range(0,l2):
133            x2[i] = x[i/2]
134
135        y2[0] = 0
136        for i in range(1,l2):
137            y2[i] = y[(i-1)/2]
138
139        self.plot(x2, y2, fmt)
140
141
[111]142    def hold(self, hold=True):
[117]143        """
144        Buffer graphics until subsequently released.
145        """
146        self.buffering = hold
[111]147
148
[618]149    def legend(self, loc=None):
[117]150        """
151        Add a legend to the plot.
[111]152
[117]153        Any other value for loc else disables the legend:
154             1: upper right
155             2: upper left
156             3: lower left
157             4: lower right
158             5: right
159             6: center left
160             7: center right
161             8: lower center
162             9: upper center
163            10: center
[111]164
[117]165        """
[618]166        if isinstance(loc,int):
167            if 0 > loc > 10: loc = 0
168            self.loc = loc
[117]169        self.show()
[111]170
171
172    def map(self):
[117]173        """
174        Reveal the ASAPlot graphics window and bring it to the top of the
175        window stack.
176        """
177        self.window.wm_deiconify()
178        self.window.lift()
[111]179
[117]180
[111]181    def palette(self, pen=None, colours=None):
[117]182        """
183        Redefine the colour sequence.
[111]184
[117]185        pen is the pen number to use for the next plot; this will be auto-
186        incremented.
[111]187
[117]188        colours is the list of pen colours.  Colour may be specified via
189        the single letter values understood by matplotlib:
[111]190
[117]191            b: blue
192            g: green
193            r: red
194            c: cyan
195            m: magenta
196            y: yellow
197            k: black
198            w: white
[111]199
[117]200        or via the full name as listed in the colour dictionary which is
201        loaded by default by load_colours() from rgb.txt and listed by
202        list_colours().
203        """
[111]204
[117]205        if pen is None and colours is None:
206            self.colours = []
207            return
[111]208
[117]209        if pen is None:
210            if not len(self.colours):
211                self.colours = [1]
212        else:
213            self.colours[0] = pen
[111]214
[117]215        if colours is None:
216            return
[111]217
[117]218        cols = []
219        for col in colours:
220            cols.append(get_colour(col))
[111]221
[117]222        self.colours[1:] = cols
[111]223
[117]224        if 0 > self.colours[0] > len(self.colours):
225            self.colours[0] = 1
[111]226
227
228    def plot(self, x=None, y=None, mask=None, fmt=None, add=None):
[117]229        """
230        Plot the next line in the current frame using the current line
231        attributes.  The ASAPlot graphics window will be mapped and raised.
[111]232
[117]233        The argument list works a bit like the matlab plot() function.
234        """
[111]235
[117]236        if x is None:
237            if y is None: return
238            x = range(len(y))
[111]239
[117]240        elif y is None:
241            y = x
242            x = range(len(y))
[111]243
[117]244        if mask is None:
245            if fmt is None:
246                line = self.axes.plot(x, y)
247            else:
248                line = self.axes.plot(x, y, fmt)
249        else:
250            segments = []
[111]251
[117]252            mask = list(mask)
253            i = 0
254            while mask[i:].count(1):
255                i += mask[i:].index(1)
256                if mask[i:].count(0):
257                    j = i + mask[i:].index(0)
258                else:
259                    j = len(mask)
[111]260
[117]261                segments.append(x[i:j])
262                segments.append(y[i:j])
[111]263
[117]264                i = j
[111]265
[117]266            line = self.axes.plot(*segments)
[111]267
[117]268        # Add to an existing line?
269        if add is None or len(self.lines) < add < 0:
[119]270            # Don't add.
[117]271            self.lines.append(line)
272            i = len(self.lines) - 1
273        else:
274            if add == 0: add = len(self.lines)
275            i = add - 1
276            self.lines[i].extend(line)
[111]277
[117]278        # Set/reset attributes for the line.
279        gotcolour = False
280        for k, v in self.attributes.iteritems():
281            if k == 'color': gotcolour = True
282            for segment in self.lines[i]:
283                getattr(segment, "set_%s"%k)(v)
[111]284
[117]285        if not gotcolour and len(self.colours):
286            for segment in self.lines[i]:
287                getattr(segment, "set_color")(self.colours[self.colours[0]])
[111]288
[117]289            self.colours[0] += 1
290            if self.colours[0] >= len(self.colours):
291                self.colours[0] = 1
[111]292
[117]293        self.show()
[111]294
295
[482]296    def position(self):
297        """
298        Use the mouse to get a position from a graph.
299        """
300
301        def position_disable(event):
302            self.register('button_press', None)
303            print '%.4f, %.4f' % (event.xdata, event.ydata)
304
305        print 'Press any mouse button...'
306        self.register('button_press', position_disable)
307
308
[111]309    def quit(self):
[117]310        """
311        Destroy the ASAPlot graphics window.
312        """
313        self.window.destroy()
[111]314
315
[482]316    def region(self):
317        """
318        Use the mouse to get a rectangular region from a plot.
319
320        The return value is [x0, y0, x1, y1] in world coordinates.
321        """
322
323        def region_start(event):
324            height = self.canvas.figure.bbox.height()
325            self.rect = {'fig': None, 'height': height,
326                         'x': event.x, 'y': height - event.y,
327                         'world': [event.xdata, event.ydata,
328                                   event.xdata, event.ydata]}
329            self.register('button_press', None)
330            self.register('motion_notify', region_draw)
331            self.register('button_release', region_disable)
332
333        def region_draw(event):
334            self.canvas._tkcanvas.delete(self.rect['fig'])
335            self.rect['fig'] = self.canvas._tkcanvas.create_rectangle(
336                                self.rect['x'], self.rect['y'],
337                                event.x, self.rect['height'] - event.y)
338
339        def region_disable(event):
340            self.register('motion_notify', None)
341            self.register('button_release', None)
342
343            self.canvas._tkcanvas.delete(self.rect['fig'])
344
345            self.rect['world'][2:4] = [event.xdata, event.ydata]
346            print '(%.2f, %.2f)  (%.2f, %.2f)' % (self.rect['world'][0],
347                self.rect['world'][1], self.rect['world'][2],
348                self.rect['world'][3])
349
350        self.register('button_press', region_start)
351
352        # This has to be modified to block and return the result (currently
353        # printed by region_disable) when that becomes possible in matplotlib.
354
355        return [0.0, 0.0, 0.0, 0.0]
356
357
[120]358    def register(self, type=None, func=None):
359        """
360        Register, reregister, or deregister events of type 'button_press',
361        'button_release', or 'motion_notify'.
[482]362
[120]363        The specified callback function should have the following signature:
364
365            def func(event)
366
367        where event is an MplEvent instance containing the following data:
368
369            name                # Event name.
370            canvas              # FigureCanvas instance generating the event.
371            x      = None       # x position - pixels from left of canvas.
372            y      = None       # y position - pixels from bottom of canvas.
373            button = None       # Button pressed: None, 1, 2, 3.
374            key    = None       # Key pressed: None, chr(range(255)), shift,
375                                  win, or control
376            inaxes = None       # Axes instance if cursor within axes.
377            xdata  = None       # x world coordinate.
378            ydata  = None       # y world coordinate.
379
380        For example:
381
382            def mouse_move(event):
383                print event.xdata, event.ydata
384
385            a = asaplot()
386            a.register('motion_notify', mouse_move)
387
388        If func is None, the event is deregistered.
389
390        Note that in TkAgg keyboard button presses don't generate an event.
391        """
392
393        if not self.events.has_key(type): return
394
395        if func is None:
396            if self.events[type] is not None:
397                # It's not clear that this does anything.
398                self.canvas.mpl_disconnect(self.events[type])
399                self.events[type] = None
400
401                # It seems to be necessary to return events to the toolbar.
402                if type == 'motion_notify':
403                    self.canvas.mpl_connect(type + '_event',
404                        self.figmgr.toolbar.mouse_move)
405                elif type == 'button_press':
406                    self.canvas.mpl_connect(type + '_event',
407                        self.figmgr.toolbar.press)
408                elif type == 'button_release':
409                    self.canvas.mpl_connect(type + '_event',
410                        self.figmgr.toolbar.release)
411
412        else:
413            self.events[type] = self.canvas.mpl_connect(type + '_event', func)
414
415
[111]416    def release(self):
[117]417        """
418        Release buffered graphics.
419        """
420        self.buffering = False
421        self.show()
[111]422
423
[482]424    def save(self, fname=None):
425        """
426        Save the plot to a file.
427
428        fname is the name of the output file.  The image format is determined
429        from the file suffix; 'png', 'ps', and 'eps' are recognized.  If no
430        file name is specified 'yyyymmdd_hhmmss.png' is created in the current
431        directory.
432        """
433        if fname is None:
434            from datetime import datetime
435            dstr = datetime.now().strftime('%Y%m%d_%H%M%S')
436            fname = 'asap'+dstr+'.png'
437
438        d = ['png','.ps','eps']
439
440        from os.path import expandvars
441        fname = expandvars(fname)
442
443        if fname[-3:].lower() in d:
444            try:
445                self.canvas.print_figure(fname)
446                print 'Written file %s' % (fname)
447            except IOError, msg:
448                print 'Failed to save %s: Error msg was\n\n%s' % (fname, err)
449                return
450        else:
451            print "Invalid image type. Valid types are:"
452            print "ps, eps, png"
453
454
[111]455    def set_axes(self, what=None, *args, **kwargs):
[117]456        """
457        Set attributes for the axes by calling the relevant Axes.set_*()
458        method.  Colour translation is done as described in the doctext
459        for palette().
460        """
[111]461
[117]462        if what is None: return
463        if what[-6:] == 'colour': what = what[:-6] + 'color'
[111]464
[117]465        newargs = {}
466        for k, v in kwargs.iteritems():
467            k = k.lower()
468            if k == 'colour': k = 'color'
[111]469
[117]470            if k == 'color':
471                v = get_colour(v)
[111]472
[117]473            newargs[k] = v
[111]474
[117]475        getattr(self.axes, "set_%s"%what)(*args, **newargs)
476        self.show()
[111]477
478
479    def set_figure(self, what=None, *args, **kwargs):
[117]480        """
481        Set attributes for the figure by calling the relevant Figure.set_*()
482        method.  Colour translation is done as described in the doctext
483        for palette().
484        """
[111]485
[117]486        if what is None: return
487        if what[-6:] == 'colour': what = what[:-6] + 'color'
488        if what[-5:] == 'color' and len(args):
489            args = (get_colour(args[0]),)
[111]490
[117]491        newargs = {}
492        for k, v in kwargs.iteritems():
493            k = k.lower()
494            if k == 'colour': k = 'color'
[111]495
[117]496            if k == 'color':
497                v = get_colour(v)
[111]498
[117]499            newargs[k] = v
[111]500
[117]501        getattr(self.figure, "set_%s"%what)(*args, **newargs)
502        self.show()
[111]503
504
[482]505    def set_limits(self, xlim=None, ylim=None):
506        """
507        Set x-, and y-limits for each subplot.
508
509        xlim = [xmin, xmax] as in axes.set_xlim().
510        ylim = [ymin, ymax] as in axes.set_ylim().
511        """
512        for s in self.subplots:
513            self.axes  = s['axes']
514            self.lines = s['lines']
[618]515            oldxlim =  list(self.axes.get_xlim())
516            oldylim =  list(self.axes.get_ylim())
517            if xlim is not None:
518                for i in range(len(xlim)):
519                    if xlim[i] is not None:
520                        oldxlim[i] = xlim[i]
521            if ylim is not None:                       
522                for i in range(len(ylim)):
523                    if ylim[i] is not None:
524                        oldylim[i] = ylim[i]
525            self.axes.set_xlim(oldxlim)
526            self.axes.set_ylim(oldylim)
527        return
[482]528
529
[111]530    def set_line(self, number=None, **kwargs):
[117]531        """
532        Set attributes for the specified line, or else the next line(s)
533        to be plotted.
[111]534
[117]535        number is the 0-relative number of a line that has already been
536        plotted.  If no such line exists, attributes are recorded and used
537        for the next line(s) to be plotted.
[111]538
[117]539        Keyword arguments specify Line2D attributes, e.g. color='r'.  Do
[111]540
[117]541            import matplotlib
542            help(matplotlib.lines)
[111]543
[117]544        The set_* methods of class Line2D define the attribute names and
545        values.  For non-US usage, "colour" is recognized as synonymous with
546        "color".
[111]547
[117]548        Set the value to None to delete an attribute.
[111]549
[117]550        Colour translation is done as described in the doctext for palette().
551        """
[111]552
[117]553        redraw = False
554        for k, v in kwargs.iteritems():
555            k = k.lower()
556            if k == 'colour': k = 'color'
[111]557
[117]558            if k == 'color':
559                v = get_colour(v)
[111]560
[117]561            if 0 <= number < len(self.lines):
562                if self.lines[number] is not None:
563                    for line in self.lines[number]:
564                        getattr(line, "set_%s"%k)(v)
565                    redraw = True
566            else:
567                if v is None:
568                    del self.attributes[k]
569                else:
570                    self.attributes[k] = v
[111]571
[117]572        if redraw: self.show()
[111]573
574
[618]575    def set_panels(self, rows=1, cols=0, n=-1, nplots=-1, ganged=True):
[118]576        """
577        Set the panel layout.
[482]578
[118]579        rows and cols, if cols != 0, specify the number of rows and columns in
580        a regular layout.   (Indexing of these panels in matplotlib is row-
581        major, i.e. column varies fastest.)
582
583        cols == 0 is interpreted as a retangular layout that accomodates
584        'rows' panels, e.g. rows == 6, cols == 0 is equivalent to
585        rows == 2, cols == 3.
586
587        0 <= n < rows*cols is interpreted as the 0-relative panel number in
588        the configuration specified by rows and cols to be added to the
589        current figure as its next 0-relative panel number (i).  This allows
590        non-regular panel layouts to be constructed via multiple calls.  Any
591        other value of n clears the plot and produces a rectangular array of
[482]592        empty panels.  The number of these may be limited by nplots.
[118]593        """
594        if n < 0 and len(self.subplots):
595            self.figure.clear()
596            self.set_title()
597
[482]598        if rows < 1: rows = 1
599
600        if cols <= 0:
[118]601            i = int(sqrt(rows))
602            if i*i < rows: i += 1
603            cols = i
604
605            if i*(i-1) >= rows: i -= 1
606            rows = i
[482]607
[118]608        if 0 <= n < rows*cols:
609            i = len(self.subplots)
610            self.subplots.append({})
[482]611
[118]612            self.subplots[i]['axes']  = self.figure.add_subplot(rows,
613                                            cols, n+1)
614            self.subplots[i]['lines'] = []
615
[119]616            if i == 0: self.subplot(0)
[118]617
[482]618            self.rows = 0
619            self.cols = 0
620
[118]621        else:
622            self.subplots = []
[482]623
624            if nplots < 1 or rows*cols < nplots:
625                nplots = rows*cols
626
627            for i in range(nplots):
[118]628                self.subplots.append({})
[482]629
[118]630                self.subplots[i]['axes']  = self.figure.add_subplot(rows,
631                                                cols, i+1)
632                self.subplots[i]['lines'] = []
[618]633                xfsize = self.subplots[i]['axes'].xaxis.label.get_size()-cols/2
634                yfsize = self.subplots[i]['axes'].yaxis.label.get_size()-rows/2
635                self.subplots[i]['axes'].xaxis.label.set_size(xfsize)
636                self.subplots[i]['axes'].yaxis.label.set_size(yfsize)
637               
638                if ganged:
639                    if rows > 1 or cols > 1:
640                        # Squeeze the plots together.
641                        pos = self.subplots[i]['axes'].get_position()
642                        if cols > 1: pos[2] *= 1.2
643                        if rows > 1: pos[3] *= 1.2
644                        self.subplots[i]['axes'].set_position(pos)
[118]645
[618]646                    # Suppress tick labelling for interior subplots.
647                    if i <= (rows-1)*cols - 1:
648                        if i+cols < nplots:
649                            # Suppress x-labels for frames width
650                            # adjacent frames
651                            for tick in \
652                                    self.subplots[i]['axes'].xaxis.majorTicks:
653                                tick.label1On = False
654                                self.subplots[i]['axes'].xaxis.label.set_visible(False)
655                    if i%cols:
656                        # Suppress y-labels for frames not in the left column.
657                        for tick in self.subplots[i]['axes'].yaxis.majorTicks:
658                            tick.label1On = False
659                        self.subplots[i]['axes'].yaxis.label.set_visible(False)
660                       
[482]661
662                self.rows = rows
663                self.cols = cols
664
[119]665            self.subplot(0)
[118]666
667    def set_title(self, title=None):
668        """
669        Set the title of the plot window.  Use the previous title if title is
670        omitted.
671        """
672        if title is not None:
673            self.title = title
674
675        self.figure.text(0.5, 0.95, self.title, horizontalalignment='center')
676
677
[111]678    def show(self):
[117]679        """
680        Show graphics dependent on the current buffering state.
681        """
682        if not self.buffering:
[618]683            if self.loc is not None:
[482]684                for j in range(len(self.subplots)):
685                    lines  = []
686                    labels = []
687                    i = 0
688                    for line in self.subplots[j]['lines']:
689                        i += 1
690                        if line is not None:
691                            lines.append(line[0])
692                            lbl = line[0].get_label()
693                            if lbl == '':
694                                lbl = str(i)
695                            labels.append(lbl)
[111]696
[482]697                    if len(lines):
698                        self.subplots[j]['axes'].legend(tuple(lines),
699                                                        tuple(labels),
700                                                        self.loc)
701                    else:
702                        self.subplots[j]['axes'].legend((' '))
[111]703
[117]704            self.window.wm_deiconify()
705            self.canvas.show()
[111]706
[119]707    def subplot(self, i=None, inc=None):
[117]708        """
[118]709        Set the subplot to the 0-relative panel number as defined by one or
710        more invokations of set_panels().
[117]711        """
[118]712        l = len(self.subplots)
713        if l:
[119]714            if i is not None:
[120]715                self.i = i
[111]716
[119]717            if inc is not None:
[120]718                self.i += inc
[117]719
[119]720            self.i %= l
721            self.axes  = self.subplots[self.i]['axes']
722            self.lines = self.subplots[self.i]['lines']
723
724
[111]725    def terminate(self):
[117]726        """
727        Clear the figure.
728        """
729        self.window.destroy()
[111]730
731
732    def text(self, *args, **kwargs):
[117]733        """
734        Add text to the figure.
735        """
736        self.figure.text(*args, **kwargs)
[482]737        self.show()
[111]738
[482]739
[111]740    def unmap(self):
[117]741        """
742        Hide the ASAPlot graphics window.
743        """
744        self.window.wm_withdraw()
[111]745
746
747def get_colour(colour='black'):
748    """
749    Look up a colour by name in the colour dictionary.  Matches are
750    case-insensitive, insensitive to blanks, and 'gray' matches 'grey'.
751    """
752
753    if colour is None: return None
754
755    if match('[rgbcmykw]$', colour): return colour
756    if match('#[\da-fA-F]{6}$', colour): return colour
757
758    if len(colours) == 0: load_colours()
759
760    # Try a quick match.
761    if colours.has_key(colour): return colours[colour]
762
763    colour = colour.replace(' ','').lower()
764    colour = colour.replace('gray','grey')
765    for name in colours.keys():
[117]766        if name.lower() == colour:
767            return colours[name]
[111]768
769    return '#000000'
770
771
772def list_colours():
773    """
774    List the contents of the colour dictionary sorted by name.
775    """
776
777    if len(colours) == 0: load_colours()
778
779    names = colours.keys()
780    names.sort()
781    for name in names:
[117]782        print colours[name], name
[111]783
784
[411]785def load_colours(filename='/usr/local/lib/rgb.txt'):
[111]786    """
787    Load the colour dictionary from the specified file.
788    """
[482]789    print 'Loading colour dictionary from', filename
[411]790    from os.path import expandvars
791    filename = expandvars(filename)
792    rgb = open(filename, 'r')
[111]793
794    while True:
[117]795        line = rgb.readline()
796        if line == '': break
797        tmp = line.split()
[111]798
[117]799        if len(tmp) == 4:
800            if tmp[3][:4] == 'gray': continue
801            if tmp[3].lower().find('gray') != -1: continue
[111]802
[117]803            name = tmp[3][0].upper() + tmp[3][1:]
804            r, g, b = int(tmp[0]), int(tmp[1]), int(tmp[2])
805            colours[name] = '#%2.2x%2.2x%2.2x' % (r, g, b)
Note: See TracBrowser for help on using the repository browser.