# This module defines a plot widget for Tk user interfaces. # It supports only elementary line plots at the moment. # See the example at the end for documentation... # # Written by Konrad Hinsen # With contributions from RajGopal Srinivasan # Last revision: 2006-6-12 # """ Plot widget for Tk user interfaces A plot widget acts like a canvas for special graphics objects that represent curves shown by lines or markers. Note that this module is not meant to replace a full-featured plot program. It was designed to permit the simple integration of plots into Tk-based user interfaces. """ import Tkinter from Scientific import N as Numeric from Canvas import Line, CanvasText import string class PolyPoints: def __init__(self, points, attr): self.points = Numeric.array(points) self.scaled = self.points self.attributes = {} for name, value in self._attributes.items(): try: value = attr[name] except KeyError: pass self.attributes[name] = value def boundingBox(self): return Numeric.minimum.reduce(self.points), \ Numeric.maximum.reduce(self.points) def scaleAndShift(self, scale=1, shift=0): self.scaled = scale*self.points+shift def writeToFile(self, file, separator): if self.points: for p in self.points: file.write(`p[0]` + ' ' + `p[1]` + '\n') return 1 else: return 0 class PolyLine(PolyPoints): """ Multiple connected lines @undocumented: draw """ def __init__(self, points, **attr): """ @param points: any sequence of (x, y) number pairs @param attr: line attributes @keyword width: the line width (default: 1) @keyword color: a string whose value is one of the color names defined in Tk (default: C{"black"}) @keyword stipple: a string whose value is the name of a bitmap defined in Tk, or C{None} for no bitmap (default: C{None}) """ PolyPoints.__init__(self, points, attr) _attributes = {'color': 'black', 'width': 1, 'stipple': None} def draw(self, canvas, bbox): if len(self.points) > 1: color = self.attributes['color'] width = self.attributes['width'] stipple = self.attributes['stipple'] arguments = (canvas,) options = {'fill': color, 'width': width} if stipple: options['stipple'] = stipple for i in range(len(self.points)-1): x1, y1 = self.scaled[i] x2, y2 = self.scaled[i+1] arguments = arguments + (x1, y1, x2, y2) apply(Line, arguments, options) class VerticalLine(PolyLine): """ A vertical line """ def __init__(self, xpos, **attr): """ @param xpos: the x coordinate of the line @type xpos: C{float} @param attr: line attributes @keyword width: the line width (default: 1) @keyword color: a string whose value is one of the color names defined in Tk (default: C{"black"}) @keyword stipple: a string whose value is the name of a bitmap defined in Tk, or C{None} for no bitmap (default: C{None}) """ apply(PolyLine.__init__, (self, 2*[(xpos, 0.)]), attr) def draw(self, canvas, bbox): self.scaled[0, 1] = bbox[0][1] self.scaled[1, 1] = bbox[1][1] PolyLine.draw(self, canvas, bbox) def writeToFile(self, file, separator): return 0 class HorizontalLine(PolyLine): """ A horizontal line """ def __init__(self, ypos, **attr): """ @param ypos: the y coordinate of the line @type ypos: C{float} @param attr: line attributes @keyword width: the line width (default: 1) @keyword color: a string whose value is one of the color names defined in Tk (default: C{"black"}) @keyword stipple: a string whose value is the name of a bitmap defined in Tk, or C{None} for no bitmap (default: C{None}) """ apply(PolyLine.__init__, (self, 2*[(0., ypos)]), attr) def draw(self, canvas, bbox): self.scaled[0, 0] = bbox[0][0] self.scaled[1, 0] = bbox[1][0] PolyLine.draw(self, canvas, bbox) def writeToFile(self, file, separator): return 0 class PolyMarker(PolyPoints): """ Series of markers """ def __init__(self, points, **attr): """ @param points: any sequence of (x, y) number pairs @param attr: marker attributes @keyword width: the line width for drawing the marker (default: 1) @keyword color: a string whose value is one of the color names defined in Tk, defines the color of the line forming the marker (default: C{"black"}) @keyword fillcolor: a string whose value is one of the color names defined in Tk, defines the color of the interior of the marker (default: C{"black"}) @keyword marker: one of C{'circle'} (default), C{'dot'}, C{'square'}, C{'triangle'}, C{'triangle_down'}, C{'cross'}, C{'plus'} """ PolyPoints.__init__(self, points, attr) _attributes = {'color': 'black', 'width': 1, 'fillcolor': 'black', 'size': 2, 'fillstyle': '', # need to make this a meaningful option. 'marker': 'circle'} def draw(self, canvas, bbox): color = self.attributes['color'] size = self.attributes['size'] fillcolor = self.attributes['fillcolor'] marker = self.attributes['marker'] fillstyle = self.attributes['fillstyle'] self._drawmarkers(canvas, self.scaled, marker, color, fillstyle, fillcolor, size) def _drawmarkers(self, c, coords, marker='circle', color='black', fillstyle='', fillcolor='', size=2): l = [] f = getattr(self, '_' + marker) for xc, yc in coords: id = f(c, xc, yc, outline=color, size=size, fill=fillcolor, fillstyle=fillstyle) if type(id) is type(()): for item in id: l.append(item) else: l.append(id) return l def _circle(self, c, xc, yc, size=1, fill='', outline='black', fillstyle=''): id = c.create_oval(xc-0.5, yc-0.5, xc+0.5, yc+0.5, fill=fill, outline=outline, stipple=fillstyle) c.scale(id, xc, yc, size*5, size*5) return id def _dot(self, c, xc, yc, size=1, fill='', outline='black', fillstyle=''): id = c.create_oval(xc-0.5, yc-0.5, xc+0.5, yc+0.5, fill=fill, outline=outline, stipple=fillstyle) c.scale(id, xc, yc, size*2.5, size*2.5) return id def _square(self, c, xc, yc, size=1, fill='', outline='black', fillstyle=''): id = c.create_rectangle(xc-0.5, yc-0.5, xc+0.5, yc+0.5, fill=fill, outline=outline, stipple=fillstyle) c.scale(id, xc, yc, size*5, size*5) return id def _triangle(self, c, xc, yc, size=1, fill='', outline='black', fillstyle=''): id = c.create_polygon(-0.5, 0.288675134595, 0.5, 0.288675134595, 0.0, -0.577350269189, fill=fill, outline=outline, stipple=fillstyle) c.move(id, xc, yc) c.scale(id, xc, yc, size*5, size*5) return id def _triangle_down(self, c, xc, yc, size=1, fill='', outline='black', fillstyle=''): id = c.create_polygon(-0.5, -0.288675134595, 0.5, -0.288675134595, 0.0, 0.577350269189, fill=fill, outline=outline, stipple=fillstyle) c.move(id, xc, yc) c.scale(id, xc, yc, size*5, size*5) return id def _cross(self, c, xc, yc, size=1, fill='black', outline=None, fillstyle=''): if outline: fill=outline id1 = c.create_line(xc-0.5, yc-0.5, xc+0.5, yc+0.5, fill=fill) id2 = c.create_line(xc-0.5, yc+0.5, xc+0.5, yc-0.5, fill=fill) c.scale(id1, xc, yc, size*5, size*5) c.scale(id2, xc, yc, size*5, size*5) return id1, id2 def _plus(self, c, xc, yc, size=1, fill='black', outline=None, fillstyle=''): if outline: fill=outline id1 = c.create_line(xc-0.5, yc, xc+0.5, yc, fill=fill) id2 = c.create_line(xc, yc+0.5, xc, yc-0.5, fill=fill) c.scale(id1, xc, yc, size*5, size*5) c.scale(id2, xc, yc, size*5, size*5) return id1, id2 class PlotGraphics: """ Compound graphics object @undocumented: boundingBox @undocumented: scaleAndShift @undocumented: draw @undocumented: writeToFile """ def __init__(self, objects): """ @param objects: a list of graphics objects (L{PolyLine}, L{PolyMarker}, L{PlotGraphics}) @type objects: C{list} """ self.objects = objects def boundingBox(self): p1, p2 = self.objects[0].boundingBox() for o in self.objects[1:]: p1o, p2o = o.boundingBox() p1 = Numeric.minimum(p1, p1o) p2 = Numeric.maximum(p2, p2o) return p1, p2 def scaleAndShift(self, scale=1, shift=0): for o in self.objects: o.scaleAndShift(scale, shift) def draw(self, canvas, bbox): for o in self.objects: o.draw(canvas, bbox) def __len__(self): return len(self.objects) def __getitem__(self, item): return self.objects[item] def writeToFile(self, file, separator): data = 0 for o in self.objects: if data: file.write(separator) data = o.writeToFile(file, separator) class PlotCanvas(Tkinter.Frame): """ Tk plot widget PlotCanvas objects support all operations of Tk widgets. """ def __init__(self, master, width, height, background='white', font="-*-helvetica-medium-r-normal--10-*-*-*-*-*-*-*", **attr): """ @param master: the parent widget @param width: the initial width of the canvas @type width: C{int} @param height: the initial height of the canvas @type height: C{int} @param background: the background color @type background: C{str} @param font: the font used for the axis labels @type font: C{str} @param attr: widget attributes @keyword zoom: a flag that indicates whether interactive zooming (using the left mouse button) is enabled; the default is C{False} (no zoom) @type zoom: C{bool} @keyword select: enables the user to select a range along the x axis by dragging the mouse (with the left button pressed) in the area B{under} the x axis. If select is 0, no selection is possible. Otherwise the value of select must be a callable object that is called whenever the selection changes, with a single argument that can be C{None} (no selection) or a tuple containing two x values. """ self.zoom = 0 if attr.has_key('zoom'): self.zoom = attr['zoom'] del attr['zoom'] self.selectfn = None if attr.has_key('select'): self.selectfn = attr['select'] del attr['select'] apply(Tkinter.Frame.__init__, (self, master), attr) self.canvas = Tkinter.Canvas(self, width=width, height=height, background=background) self.canvas.pack(fill=Tkinter.BOTH, expand=Tkinter.YES) border_w = self.canvas.winfo_reqwidth() - \ string.atoi(self.canvas.cget('width')) border_h = self.canvas.winfo_reqheight() - \ string.atoi(self.canvas.cget('height')) self.border = (border_w, border_h) self.canvas.bind('', self.reconfigure) if self.zoom or self.selectfn is not None: self.mouse_state = 0 self.canvas.bind('', self._mousePressed) self.canvas.bind('', self._mouseMotion) self.canvas.bind('', self._mouseRelease) self.popup_menu = Tkinter.Menu(self.canvas, tearoff=0) self.label = None self.canvas.bind('', self._showValue) self.canvas.bind('', self._hideValue) self.popup_menu.add_command(label='Auto Scale', command=self._autoScale) self.popup_menu.add_command(label='Run Xmgrace', command=self._xmgr) self.canvas.bind('', self._popupMenu) self._setsize() self.last_draw = None self.font = self._testFont(font) self.rubberband = None self.rectangle = None self.selected_range = None def reconfigure(self, event): new_width = event.width-self.border[0] new_height = event.height-self.border[1] width = string.atoi(self.canvas.cget('width')) height = string.atoi(self.canvas.cget('height')) if new_width == width and new_height == height: return self.canvas.configure(width=new_width, height=new_height) self._setsize() self.clear() self.redraw() def bind(self, *args): apply(self.canvas.bind, args) def _testFont(self, font): if font is not None: bg = self.canvas.cget('background') try: item = CanvasText(self.canvas, 0, 0, anchor=Tkinter.NW, text='0', fill=bg, font=font) self.canvas.delete(item) except Tkinter.TclError: font = None return font def _setsize(self): self.width = string.atoi(self.canvas.cget('width')) self.height = string.atoi(self.canvas.cget('height')) self.plotbox_size = 0.97*Numeric.array([self.width, -self.height]) xo = 0.5*(self.width-self.plotbox_size[0]) yo = self.height-0.5*(self.height+self.plotbox_size[1]) self.plotbox_origin = Numeric.array([xo, yo]) def draw(self, graphics, xaxis = None, yaxis = None): """ Draw something on the canvas @param graphics: the graphics object (L{PolyLine}, L{PolyMarker}, or L{PlotGraphics}) to be drawn @param xaxis: C{None} (no x-axis), C{"automatic"} (automatic scaling), or a pair (x1, x2) defining the range of the x-axis @param yaxis: C{None} (no y-axis), C{"automatic"} (automatic scaling), or a pair (y1, y2) defining the range of the y-axis """ self.last_draw = (graphics, xaxis, yaxis) p1, p2 = graphics.boundingBox() xaxis = self._axisInterval(xaxis, p1[0], p2[0]) yaxis = self._axisInterval(yaxis, p1[1], p2[1]) text_width = [0., 0.] text_height = [0., 0.] if xaxis is not None: p1[0] = xaxis[0] p2[0] = xaxis[1] xticks = self._ticks(xaxis[0], xaxis[1]) bb = self._textBoundingBox(xticks[0][1]) text_height[1] = bb[3]-bb[1] text_width[0] = 0.5*(bb[2]-bb[0]) bb = self._textBoundingBox(xticks[-1][1]) text_width[1] = 0.5*(bb[2]-bb[0]) else: xticks = None if yaxis is not None: p1[1] = yaxis[0] p2[1] = yaxis[1] yticks = self._ticks(yaxis[0], yaxis[1]) for y in yticks: bb = self._textBoundingBox(y[1]) w = bb[2]-bb[0] text_width[0] = max(text_width[0], w) h = 0.5*(bb[3]-bb[1]) text_height[0] = h text_height[1] = max(text_height[1], h) else: yticks = None text1 = Numeric.array([text_width[0], -text_height[1]]) text2 = Numeric.array([text_width[1], -text_height[0]]) scale = (self.plotbox_size-text1-text2) / (p2-p1) shift = -p1*scale + self.plotbox_origin + text1 self.transformation = (scale, shift) self.bbox = (p1, p2) self._drawAxes(self.canvas, xaxis, yaxis, p1, p2, scale, shift, xticks, yticks) graphics.scaleAndShift(scale, shift) graphics.draw(self.canvas, (scale*p1+shift, scale*p2+shift)) def _axisInterval(self, spec, lower, upper): if spec is None: return None if spec == 'minimal': if lower == upper: return lower-0.5, upper+0.5 else: return lower, upper if spec == 'automatic': range = upper-lower if range == 0.: return lower-0.5, upper+0.5 log = Numeric.log10(range) power = Numeric.floor(log) fraction = log-power if fraction <= 0.05: power = power-1 grid = 10.**power lower = lower - lower % grid mod = upper % grid if mod != 0: upper = upper - mod + grid return lower, upper if type(spec) == type(()): lower, upper = spec if lower <= upper: return lower, upper else: return upper, lower raise ValueError(str(spec) + ': illegal axis specification') def _drawAxes(self, canvas, xaxis, yaxis, bb1, bb2, scale, shift, xticks, yticks): dict = {'anchor': Tkinter.N, 'fill': 'black'} if self.font is not None: dict['font'] = self.font if xaxis is not None: lower, upper = xaxis text = 1 for y, d in [(bb1[1], -3), (bb2[1], 3)]: p1 = scale*Numeric.array([lower, y])+shift p2 = scale*Numeric.array([upper, y])+shift Line(self.canvas, p1[0], p1[1], p2[0], p2[1], fill = 'black', width = 1) for x, label in xticks: p = scale*Numeric.array([x, y])+shift Line(self.canvas, p[0], p[1], p[0], p[1]+d, fill = 'black', width = 1) if text: dict['text'] = label apply(CanvasText, (self.canvas, p[0], p[1]), dict) text = 0 dict['anchor'] = Tkinter.E if yaxis is not None: lower, upper = yaxis text = 1 for x, d in [(bb1[0], -3), (bb2[0], 3)]: p1 = scale*Numeric.array([x, lower])+shift p2 = scale*Numeric.array([x, upper])+shift Line(self.canvas, p1[0], p1[1], p2[0], p2[1], fill = 'black', width = 1) for y, label in yticks: p = scale*Numeric.array([x, y])+shift Line(self.canvas, p[0], p[1], p[0]-d, p[1], fill = 'black', width = 1) if text: dict['text'] = label apply(CanvasText, (self.canvas, p[0], p[1]), dict) text = 0 def _ticks(self, lower, upper): ideal = (upper-lower)/7. if ideal == 0.: ideal = 1./7. log = Numeric.log10(ideal) power = Numeric.floor(log) fraction = log-power factor = 1. error = fraction for f, lf in self._multiples: e = Numeric.fabs(fraction-lf) if e < error: error = e factor = f grid = factor * 10.**power if power > 3 or power < -3: format = '%+7.0e' elif power >= 0: digits = max(1, int(power)) format = '%' + `digits`+'.0f' else: digits = -int(power) format = '%'+`digits+2`+'.'+`digits`+'f' ticks = [] t = -grid*Numeric.floor(-lower/grid) while t <= upper and len(ticks) < 200: ticks.append((t, format % (t,))) t = t + grid return ticks _multiples = [(2., Numeric.log10(2.)), (5., Numeric.log10(5.))] def _textBoundingBox(self, text): bg = self.canvas.cget('background') dict = {'anchor': Tkinter.NW, 'text': text, 'fill': bg} if self.font is not None: dict['font'] = self.font item = apply(CanvasText, (self.canvas, 0., 0.), dict) bb = self.canvas.bbox(item) self.canvas.delete(item) return bb def clear(self): """ Clear the canvas """ self.canvas.delete('all') self.rectangle = None self.rubberband = None self.selected_range = None def redraw(self): """ Redraw the most recent canvas contents """ if self.last_draw is not None: apply(self.draw, self.last_draw) def _mousePressed(self, event): self.startx = self.canvas.canvasx(event.x) self.starty = self.canvas.canvasy(event.y) def _mouseMotion(self, event): if self.mouse_state == 0: scale, shift = self.transformation p = (Numeric.array([self.startx, self.starty])-shift)/scale bb1, bb2 = self.bbox if self.selectfn is not None and p[1] < bb1[1]: self.mouse_state = 2 self.canvas.delete(self.rectangle) self.rectangle = \ self.canvas.create_rectangle(self.startx, self.starty, self.startx, self.starty, fill='yellow', outline='', stipple='gray50', width=0) self.canvas.lower(self.rectangle) elif self.zoom: self.mouse_state = 1 if self.mouse_state == 1: x = self.canvas.canvasx(event.x) y = self.canvas.canvasy(event.y) if (self.startx != event.x) and (self.starty != event.y) : self.canvas.delete(self.rubberband) self.rubberband = self.canvas.create_rectangle(self.startx, self.starty, x, y) self.update_idletasks() elif self.mouse_state == 2: self.canvas.coords(self.rectangle, self.startx, 1., self.canvas.canvasx(event.x), self.height) self.update_idletasks() def _mouseRelease(self, event): if self.mouse_state == 1: self.canvas.delete(self.rubberband) self.rubberband = None p1 = Numeric.array([self.startx, self.starty]) p2 = Numeric.array([self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)]) if Numeric.minimum.reduce(Numeric.fabs(p1-p2)) > 5: scale, shift = self.transformation p1 = (p1-shift)/scale p2 = (p2-shift)/scale graphics, xaxis, yaxis = self.last_draw if xaxis is not None: xaxis = (p1[0], p2[0]) if yaxis is not None: yaxis = (p2[1], p1[1]) self.clear() self.draw(graphics, xaxis, yaxis) elif self.mouse_state == 2: scale, shift = self.transformation x1 = (self.startx-shift[0])/scale[0] x2 = (self.canvas.canvasx(event.x)-shift[0])/scale[0] if x1 < x2: self.selected_range = (x1, x2) else: self.selected_range = (x2, x1) if self.selectfn is not None: self.selectfn(self.selected_range) else: self.canvas.delete(self.rectangle) self.rectangle = None self.selected_range = None if self.selectfn is not None: self.selectfn(self.selected_range) self.mouse_state = 0 def select(self, range): """ Highlight a range on the x-axis @param range: the range on the x-axis to be highlighted. It can be C{None} (no selection) or a sequence of two values on the x-axis. """ if range is None: if self.selected_range is not None: self.canvas.delete(self.rectangle) self.rectangle = None self.selected_range = None else: scale, shift = self.transformation x1 = scale[0]*range[0]+shift[0] x2 = scale[0]*range[1]+shift[0] if self.rectangle is None: self.rectangle = \ self.canvas.create_rectangle(x1, 1., x2, self.height, fill='yellow', outline='', stipple='gray50', width=0) self.canvas.lower(self.rectangle) else: self.canvas.coords(self.rectangle, x1, 1., x2, self.height) self.selected_range = range self.update_idletasks() def _popupMenu(self, event): self.popup_menu.post(event.x_root, event.y_root) def _autoScale(self): if self.last_draw is not None: graphics, xaxis, yaxis = self.last_draw if xaxis is not None: xaxis = 'automatic' if yaxis is not None: yaxis = 'automatic' self.clear() self.draw(graphics, xaxis, yaxis) def _xmgr(self): if self.last_draw is not None: import os, tempfile filename = tempfile.mktemp() file = open(filename, 'w') graphics, xaxis, yaxis = self.last_draw graphics.writeToFile(file, '!\n') file.close() os.system('(xmgrace %s ; rm %s ) &' % (filename, filename)) def _showValue(self, event): scale, shift = self.transformation x = self.canvas.canvasx(event.x) y = self.canvas.canvasy(event.y) point = Numeric.array([x, y]) point = (point-shift)/scale text = "x = %f\ny = %f" % tuple(point) self.label = self.canvas.create_window(x, y, window=Label(self.canvas, text=text)) def _hideValue(self, event): if self.label is not None: self.canvas.delete(self.label) self.label = None if __name__ == '__main__': window = Tkinter.Frame() window.pack(fill=Tkinter.BOTH, expand=Tkinter.YES) data1 = 2.*Numeric.pi*Numeric.arange(200)/200. data1.shape = (100, 2) data1[:,1] = Numeric.sin(data1[:,0]) lines1 = PolyLine(data1, color='green') pi = Numeric.pi lines2 = PolyLine([(0., 0.), (pi/2., 1.), (pi, 0.), (3.*pi/2., -1), (2.*pi, 0.)], color='red') markers = PolyMarker([(0., 0.), (pi/2., 1.), (pi, 0.), (3.*pi/2., -1), (2.*pi, 0.)], color='blue', fillcolor='blue', marker='triangle') object = PlotGraphics([lines1, lines2, markers]) def display(value): select(value) print value c = PlotCanvas(window, "300", "200", relief=Tkinter.SUNKEN, border=2, zoom = 1, select = display) c.pack(side=Tkinter.TOP, fill=Tkinter.BOTH, expand=Tkinter.YES) def select(value): c.select(value) Tkinter.Button(window, text='Draw', command=lambda o=object: c.draw(o, 'automatic', 'automatic')).pack(side=Tkinter.LEFT) Tkinter.Button(window, text='Clear', command=c.clear).pack(side=Tkinter.LEFT) Tkinter.Button(window, text='Redraw', command=c.redraw).pack(side=Tkinter.LEFT) Tkinter.Button(window, text='Quit', command=window.quit).pack(side=Tkinter.RIGHT) window.mainloop()