Cleanup for AxisItem

- Made more extensible by breaking out tick spacing and text generating into separate methods
 - Text now tries harder to avoid overlapping
This commit is contained in:
Luke Campagnola 2012-04-04 09:31:58 -04:00
parent 78d4bc0838
commit 6aef85331e

View File

@ -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.
"""
@ -259,6 +260,100 @@ class AxisItem(GraphicsWidget):
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):
p.setRenderHint(p.Antialiasing, False)
@ -272,174 +367,115 @@ 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
tickPositions[i].append(x)
prof.mark('draw ticks')
## 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
@ -456,14 +492,8 @@ class AxisItem(GraphicsWidget):
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))
prof.mark('draw ticks')
for args in texts:
p.setPen(QtGui.QPen(QtGui.QColor(150, 150, 150, args[3])))
p.drawText(*args[:3])
p.setPen(QtGui.QPen(QtGui.QColor(150, 150, 150)))
p.drawText(rect, textFlags, vstr)
prof.mark('draw text')
prof.finish()