From 6aef85331e0f5d920dd418b3b87c7eaa31302c87 Mon Sep 17 00:00:00 2001 From: Luke Campagnola <> Date: Wed, 4 Apr 2012 09:31:58 -0400 Subject: [PATCH] Cleanup for AxisItem - Made more extensible by breaking out tick spacing and text generating into separate methods - Text now tries harder to avoid overlapping --- graphicsItems/AxisItem.py | 314 +++++++++++++++++++++----------------- 1 file changed, 172 insertions(+), 142 deletions(-) diff --git a/graphicsItems/AxisItem.py b/graphicsItems/AxisItem.py index 4015f8fe..563f3fb4 100644 --- a/graphicsItems/AxisItem.py +++ b/graphicsItems/AxisItem.py @@ -13,6 +13,7 @@ class AxisItem(GraphicsWidget): GraphicsItem showing a single plot axis with ticks, values, and label. Can be configured to fit on any side of a plot, and can automatically synchronize its displayed scale with ViewBox items. Ticks can be extended to make a grid. + If maxTickLength is negative, ticks point into the plot. """ @@ -258,6 +259,100 @@ class AxisItem(GraphicsWidget): painter.end() self.picture.play(p) + + + def tickSpacing(self, minVal, maxVal, size): + """Return values describing the desired spacing and offset of ticks. + + This method is called whenever the axis needs to be redrawn and is a + good method to override in subclasses that require control over tick locations. + + The return value must be a list of three tuples: + [ + (major tick spacing, offset), + (minor tick spacing, offset), + (sub-minor tick spacing, offset), + ... + ] + """ + dif = abs(maxVal - minVal) + if dif == 0: + return [] + + ## decide optimal minor tick spacing in pixels (this is just aesthetics) + pixelSpacing = np.log(size+10) * 5 + optimalTickCount = size / pixelSpacing + if optimalTickCount < 1: + optimalTickCount = 1 + + ## optimal minor tick spacing + optimalSpacing = dif / optimalTickCount + + ## the largest power-of-10 spacing which is smaller than optimal + p10unit = 10 ** np.floor(np.log10(optimalSpacing)) + + ## Determine major/minor tick spacings which flank the optimal spacing. + intervals = np.array([1., 2., 10., 20., 100.]) * p10unit + minorIndex = 0 + while intervals[minorIndex+1] <= optimalSpacing: + minorIndex += 1 + + return [ + (intervals[minorIndex+2], 0), + (intervals[minorIndex+1], 0), + (intervals[minorIndex], 0) + ] + + + def tickValues(self, minVal, maxVal, size): + """ + Return the values and spacing of ticks to draw + [ + (spacing, [major ticks]), + (spacing, [minor ticks]), + ... + ] + + By default, this method calls tickSpacing to determine the correct tick locations. + This is a good method to override in subclasses. + """ + ticks = [] + tickLevels = self.tickSpacing(minVal, maxVal, size) + for i in range(len(tickLevels)): + spacing, offset = tickLevels[i] + + ## determine starting tick + start = (np.ceil((minVal-offset) / spacing) * spacing) + offset + + ## determine number of ticks + num = int((maxVal-start) / spacing) + 1 + ticks.append((spacing, np.arange(num) * spacing + start)) + return ticks + + + def tickStrings(self, values, scale, spacing): + """Return the strings that should be placed next to ticks. This method is called + when redrawing the axis and is a good method to override in subclasses. + The method is called with a list of tick values, a scaling factor (see below), and the + spacing between ticks (this is required since, in some instances, there may be only + one tick and thus no other way to determine the tick spacing) + + The scale argument is used when the axis label is displaying units which may have an SI scaling prefix. + When determining the text to display, use value*scale to correctly account for this prefix. + For example, if the axis label's units are set to 'V', then a tick value of 0.001 might + be accompanied by a scale value of 1000. This indicates that the label is displaying 'mV', and + thus the tick should display 0.001 * 1000 = 1. + """ + places = max(0, np.ceil(-np.log10(spacing*scale))) + strings = [] + for v in values: + vs = v * scale + if abs(vs) < .001 or abs(vs) >= 10000: + vstr = "%g" % vs + else: + vstr = ("%%0.%df" % places) % vs + strings.append(vstr) + return strings def drawPicture(self, p): @@ -272,198 +367,133 @@ class AxisItem(GraphicsWidget): linkedView = self.linkedView() if linkedView is None or self.grid is False: - tbounds = bounds + tickBounds = bounds else: - tbounds = linkedView.mapRectToItem(self, linkedView.boundingRect()) + tickBounds = linkedView.mapRectToItem(self, linkedView.boundingRect()) if self.orientation == 'left': span = (bounds.topRight(), bounds.bottomRight()) - tickStart = tbounds.right() + tickStart = tickBounds.right() tickStop = bounds.right() tickDir = -1 axis = 0 elif self.orientation == 'right': span = (bounds.topLeft(), bounds.bottomLeft()) - tickStart = tbounds.left() + tickStart = tickBounds.left() tickStop = bounds.left() tickDir = 1 axis = 0 elif self.orientation == 'top': span = (bounds.bottomLeft(), bounds.bottomRight()) - tickStart = tbounds.bottom() + tickStart = tickBounds.bottom() tickStop = bounds.bottom() tickDir = -1 axis = 1 elif self.orientation == 'bottom': span = (bounds.topLeft(), bounds.topRight()) - tickStart = tbounds.top() + tickStart = tickBounds.top() tickStop = bounds.top() tickDir = 1 axis = 1 - + #print tickStart, tickStop, span + ## draw long line along axis p.drawLine(*span) + p.translate(0.5,0) ## resolves some damn pixel ambiguity ## determine size of this item in pixels points = map(self.mapToDevice, span) lengthInPixels = Point(points[1] - points[0]).length() - - ## decide optimal tick spacing in pixels - pixelSpacing = np.log(lengthInPixels+10) * 2 - optimalTickCount = lengthInPixels / pixelSpacing - - ## Determine optimal tick spacing - #intervals = [1., 2., 5., 10., 20., 50.] - #intervals = [1., 2.5, 5., 10., 25., 50.] - intervals = np.array([0.1, 0.2, 1., 2., 10., 20., 100., 200.]) - dif = abs(self.range[1] - self.range[0]) - if dif == 0.0: + if lengthInPixels == 0: return - pw = 10 ** (np.floor(np.log10(dif))-1) - scaledIntervals = intervals * pw - scaledTickCounts = dif / scaledIntervals - try: - i1 = np.argwhere(scaledTickCounts < optimalTickCount)[0,0] - except: - print "AxisItem can't determine tick spacing:" - print "scaledTickCounts", scaledTickCounts - print "optimalTickCount", optimalTickCount - print "dif", dif - print "scaledIntervals", scaledIntervals - print "intervals", intervals - print "pw", pw - print "pixelSpacing", pixelSpacing - i1 = 1 - - distBetweenIntervals = (optimalTickCount-scaledTickCounts[i1]) / (scaledTickCounts[i1-1]-scaledTickCounts[i1]) - - #print optimalTickCount, i1, scaledIntervals, distBetweenIntervals - - #for i in range(len(intervals)): - #i1 = i - #if dif / (pw*intervals[i]) < 10: - #break + + + tickLevels = self.tickValues(self.range[0], self.range[1], lengthInPixels) textLevel = 1 ## draw text at this scale level - #print "range: %s dif: %f power: %f interval: %f spacing: %f" % (str(self.range), dif, pw, intervals[i1], sp) - - #print " start at %f, %d ticks" % (start, num) - - + ## determine mapping between tick values and local coordinates + dif = self.range[1] - self.range[0] if axis == 0: - xs = -bounds.height() / dif + xScale = -bounds.height() / dif + offset = self.range[0] * xScale - bounds.height() else: - xs = bounds.width() / dif + xScale = bounds.width() / dif + offset = self.range[0] * xScale prof.mark('init') - tickPositions = set() # remembers positions of previously drawn ticks - ## draw ticks and generate list of texts to draw + tickPositions = [] # remembers positions of previously drawn ticks + + ## draw ticks ## (to improve performance, we do not interleave line and text drawing, since this causes unnecessary pipeline switching) ## draw three different intervals, long ticks first - texts = [] - for i in [2,1,0]: - if i1+i >= len(intervals) or i1+i < 0: - print "AxisItem.paint error: i1=%d, i=%d, len(intervals)=%d" % (i1, i, len(intervals)) - continue - - ## spacing for this interval - sp = pw*intervals[i1+i] - - ## determine starting tick - start = np.ceil(self.range[0] / sp) * sp - - ## determine number of ticks - num = int(dif / sp) + 1 - - ## last tick value - last = start + sp * num - - ## Number of decimal places to print - maxVal = max(abs(start), abs(last)) - places = max(0, np.ceil(-np.log10(sp*self.scale))) - #print i, sp, sp*self.scale, np.log10(sp*self.scale), places + for i in range(len(tickLevels)): + tickPositions.append([]) + ticks = tickLevels[i][1] ## length of tick - #h = np.clip((self.tickLength*3 / num) - 1., min(0, self.tickLength), max(0, self.tickLength)) - if i == 0: - h = self.tickLength * distBetweenIntervals / 2. - else: - h = self.tickLength*i/2. - - ## alpha - if i == 0: - #a = min(255, (765. / num) - 1.) - a = 255 * distBetweenIntervals - else: - a = 255 - - lineAlpha = a - textAlpha = a + tickLength = self.tickLength / ((i*1.0)+1.0) + lineAlpha = 255 / (i+1) if self.grid is not False: - lineAlpha = int(lineAlpha * self.grid / 255.) + lineAlpha = self.grid - if axis == 0: - offset = self.range[0] * xs - bounds.height() - else: - offset = self.range[0] * xs - - for j in range(num): - v = start + sp * j - x = (v * xs) - offset - p1 = [0, 0] - p2 = [0, 0] + for v in ticks: + x = (v * xScale) - offset + p1 = [x, x] + p2 = [x, x] p1[axis] = tickStart p2[axis] = tickStop if self.grid is False: - p2[axis] += h*tickDir - p1[1-axis] = p2[1-axis] = x - - if p1[1-axis] > [bounds.width(), bounds.height()][1-axis]: - continue - if p1[1-axis] < 0: - continue + p2[axis] += tickLength*tickDir p.setPen(QtGui.QPen(QtGui.QColor(150, 150, 150, lineAlpha))) - - # draw tick only if there is none - tickPos = p1[1-axis] - - #if tickPos not in tickPositions: p.drawLine(Point(p1), Point(p2)) - #tickPositions.add(tickPos) - if i == textLevel: - if abs(v*self.scale) < .001 or abs(v*self.scale) >= 10000: - vstr = "%g" % (v * self.scale) - else: - vstr = ("%%0.%df" % places) % (v * self.scale) - #print " ", v*self.scale, places, vstr - - textRect = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, vstr) - height = textRect.height() - self.textHeight = height - if self.orientation == 'left': - textFlags = QtCore.Qt.AlignRight|QtCore.Qt.AlignVCenter - rect = QtCore.QRectF(tickStop-100, x-(height/2), 99-max(0,self.tickLength), height) - elif self.orientation == 'right': - textFlags = QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter - rect = QtCore.QRectF(tickStop+max(0,self.tickLength)+1, x-(height/2), 100-max(0,self.tickLength), height) - elif self.orientation == 'top': - textFlags = QtCore.Qt.AlignCenter|QtCore.Qt.AlignBottom - rect = QtCore.QRectF(x-100, tickStop-max(0,self.tickLength)-height, 200, height) - elif self.orientation == 'bottom': - textFlags = QtCore.Qt.AlignCenter|QtCore.Qt.AlignTop - rect = QtCore.QRectF(x-100, tickStop+max(0,self.tickLength), 200, height) - - #p.setPen(QtGui.QPen(QtGui.QColor(150, 150, 150, a))) - #p.drawText(rect, textFlags, vstr) - texts.append((rect, textFlags, vstr, textAlpha)) - + tickPositions[i].append(x) prof.mark('draw ticks') - for args in texts: - p.setPen(QtGui.QPen(QtGui.QColor(150, 150, 150, args[3]))) - p.drawText(*args[:3]) + + ## determine level to draw text + best = 0 + for i in range(len(tickLevels)): + ## take a small sample of strings and measure their rendered text + spacing, values = tickLevels[i] + strings = self.tickStrings(values[:2], self.scale, spacing) + textRects = [p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, s) for s in strings] + if axis == 0: + textSize = np.max([r.height() for r in textRects]) + else: + textSize = np.max([r.width() for r in textRects]) + + ## If these strings are not too crowded, then this level is ok + textFillRatio = float(textSize * len(values)) / lengthInPixels + if textFillRatio < 0.7: + best = i + continue + prof.mark('measure text') + + spacing, values = tickLevels[best] + strings = self.tickStrings(values, self.scale, spacing) + for j in range(len(strings)): + vstr = strings[j] + x = tickPositions[best][j] + textRect = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, vstr) + height = textRect.height() + self.textHeight = height + if self.orientation == 'left': + textFlags = QtCore.Qt.AlignRight|QtCore.Qt.AlignVCenter + rect = QtCore.QRectF(tickStop-100, x-(height/2), 99-max(0,self.tickLength), height) + elif self.orientation == 'right': + textFlags = QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter + rect = QtCore.QRectF(tickStop+max(0,self.tickLength)+1, x-(height/2), 100-max(0,self.tickLength), height) + elif self.orientation == 'top': + textFlags = QtCore.Qt.AlignCenter|QtCore.Qt.AlignBottom + rect = QtCore.QRectF(x-100, tickStop-max(0,self.tickLength)-height, 200, height) + elif self.orientation == 'bottom': + textFlags = QtCore.Qt.AlignCenter|QtCore.Qt.AlignTop + rect = QtCore.QRectF(x-100, tickStop+max(0,self.tickLength), 200, height) + + p.setPen(QtGui.QPen(QtGui.QColor(150, 150, 150))) + p.drawText(rect, textFlags, vstr) prof.mark('draw text') prof.finish()