1
""" General purpose classes for plotting utility.
3
Many of these classes and function are helpful for laying out the
4
location of element of a graph (titles, tick labels, ticks, etc).
5
A simple class for handling properties of graph elements is also
6
included. Everything here is independent of any specific GUI or
9
The classes and functions break out into 3 main sections:
12
class box_object -- Helps align rectangular graph elements
13
relative to one another. Currently only
14
Int values are allowed for box dimensions.
15
class point_object -- Alignment of points with one-another or
18
class poperty_object -- base class for any graph object that
19
manages a set of properties. Pretty
20
low-tech compared to graphite and Zope.
21
It could use additions for error checking.
23
Axis layout functions:
24
auto_ticks -- find set of tick locations for an axis.
25
format_tick_labels -- convert the numerical tick values to strings
27
auto_interval -- determine an appropriate tick interval for axis
28
auto_bounds -- determine appropriate end points for axis
33
#----------------------------------------------------------
34
# General layout classes
35
#----------------------------------------------------------
37
from numpy.core.umath import *
38
import numpy.limits as limits
41
LEFT,RIGHT,TOP,BOTTOM = 0,1,2,3 # used by same_as() method
43
TYPE = Int # all values in a box_object are forced to be integers
44
# might change this later when we've looked at round off
45
# issues more thoroughly.
48
""" Helpful for laying out rectangles.
50
This class has three general areas of functionality:
51
1. Retreive information about a box such as it's size,
52
the x-coordinate of its left side, and others.
53
2. Specify the location of one box (rectangle) in relation to
54
another using methods such as left_of(), above(),
56
3. Change the size (and location) of a box by either choping off
57
a portion of the box using trim_right(), trim_left(), and
58
others or inflating the box by a percentage.
60
def __init__(self,topleft,size):
61
self.topleft = asarray(topleft,TYPE)
62
self._size = asarray(size,TYPE)
64
#--------------- interrogation functions --------------------#
66
"Return the x-coordinate of the left edge."
67
return self.topleft[0]
69
"Return the x-coordinate of the right edge."
70
return self.topleft[0] + self.width()
72
"Return the y-coordinate of the top edge."
73
return self.topleft[1]
75
"Return the y-coordinate of the bottom edge."
76
return self.topleft[1] + self.height()
78
"Return the x-coordinate of the boxes center."
79
return self.topleft[0] + .5 * self.width()
81
"Return the y-coordinate of the left edge."
82
return self.topleft[1] + .5 * self.height()
84
"Return the size of the box as array((width,height))."
87
"Return the width of the box (length along x-axis)"
90
"Return the width of the box (length along y-axis)"
93
"Return the coordinates of the boxes center as an array"
94
return self.topleft + .5 * self.size()
95
def radial(self,angle):
96
""" Length of a ray extending from the center of the box at the
97
specified polar angle (in radians) to the edge of the box"""
98
# Could have some div_by_zero errors if h or w = 0
99
# --> haven't thought about this much
101
slope = abs(tan(angle))
102
if slope > float(h)/w:
108
return sqrt(s1**2+s2**2)
110
#------------- Moving and resizing methods ----------------#
111
def move(self,location):
112
""" Move box so that its top left corner is located at
113
the specified location. Location is an (x,y) pair."""
114
self.topleft = asarray(location,TYPE)
115
def translate(self,offset):
116
""" Shift the box's location by the amount specifed in
117
offset. Offset is an (x,y) pair."""
118
self.topleft = self.topleft + asarray(offset,TYPE)
119
def set_size(self,sz):
120
""" Set the size of the box. sz is an (x,y) pair."""
121
self._size = asarray(sz,TYPE)
122
def set_width(self,width):
123
""" Set the width of the box. sz is a numeric value"""
124
self.set_size((width,self.height()))
125
def set_height(self,height):
126
""" Set the height of the box. sz is a numeric value."""
127
self.set_size((self.width(),height))
129
#-------------------- Relational Positioning ----------------#
130
def above(self,other_box,margin=0):
131
"""Move box so that its bottom edge has same y-coord of the
132
top edge of other_box. The size does not change. Optionally,
133
margin specifies the gap between the boxes."""
134
self.topleft[1] = other_box.top() - self.height() - margin
135
def below(self,other_box,margin=0):
136
"""Move box so that its top edge has same y-coord of the
137
bottom edge of other_box. The size does not change. Optionally,
138
margin specifies the gap between the boxes."""
139
self.topleft[1] = other_box.bottom() + margin
140
def left_of(self,other_box,margin=0):
141
"""Move box so that its right edge has same x-coord of the
142
left edge of other_box. The size does not change. Optionally,
143
margin specifies the gap between the boxes."""
144
self.topleft[0] = other_box.left() - self.width() - margin
145
def right_of(self,other_box,margin=0):
146
"""Move box so that its left edge has same x-coord of the
147
right edge of other_box. The size does not change. Optionally,
148
margin specifies the gap between the boxes."""
149
self.topleft[0] = other_box.right() + margin
150
def center_on_x_of(self,other_box):
151
"""Move box so that x-coord of its center equals the x-coord of
152
other_box's center. The size does not change."""
153
self.topleft[0] = other_box.center_x() - .5 * self.width()
154
def center_on_y_of(self,other_box):
155
"""Move box so that y-coord of its center equals the y-coord of
156
other_box's center. The size does not change."""
157
self.topleft[1] = other_box.center_y() - .5 * self.height()
158
def center_on(self,other_box):
159
"""Move box so that its center is the same as other_box's center.
160
The size does not change."""
161
self.center_on_x_of(other_box)
162
self.center_on_y_of(other_box)
163
def same_as(self,other_box,edge,margin=0):
164
"""Set the specified edge of this box to be aligned with the
165
same edge of other_box. The size does not change.
168
self.topleft[0] = other_box.left() + margin
170
self.topleft[0] = other_box.right() - margin
172
self.topleft[1] = other_box.top() + margin
174
self.topleft[1] = other_box.bottom() - margin
176
raise ValueError, "edge should only be plt.xxx where xxx is" \
177
" LEFT,RIGHT,TOP, or BOTTOM"
178
def radial_offset_from(self,other_box,angle,margin=0):
179
""" Move this box so that its center is aligned along a radial ray
180
out of other_box's center at the specified angle (in radians),
181
and its edge touches the appropriate edge of other_box.
182
Optionally, margin specifies the size of the gap (along the ray)
183
between the two boxes.
185
dist = other_box.radial(angle) + margin + self.radial(angle)
186
new_center = other_box.center() + rotate((dist,0),(0,0),angle)
187
self.center_on(point_object(new_center.astype(TYPE)))
189
#------------------ Trim the edges of the box ----------------#
190
def trim_top(self,amount,margin=0):
191
""" Remove amount from the top edge of the box. The size and
192
topleft corner of the box are altered appropriately. Margin
193
specifies an additional amount to be removed.
195
self.topleft[1] = self.topleft[1] + amount
196
self.set_height( self.height() - amount - margin)
197
def trim_left(self,amount,margin=0):
198
""" Remove amount from the left edge of the box. The size and
199
topleft corner of the box are altered appropriately. Margin
200
specifies an additional amount to be removed.
202
self.topleft[0] = self.topleft[0] + amount
203
self.set_width( self.width() - amount - margin)
204
def trim_right(self,amount,margin=0):
205
""" Remove amount from the right edge of the box. The size and
206
is altered appropriately. Margin specifies an additional amount
209
self.set_width(self.width() - amount - margin)
210
def trim_bottom(self,amount,margin=0):
211
""" Remove amount from the bottom edge of the box. The size
212
is altered appropriately. Margin specifies an additional
213
amount to be removed.
215
self.set_height(self.height() - amount - margin)
216
def trim_all(self,amount,margin=0):
217
""" Remove amount from the all edges of the box. The size and
218
topleft corner of the bow are altered appropriately. Margin
219
specifies an additional amount to be removed.
221
self.trim_top(amount,margin)
222
self.trim_bottom(amount,margin)
223
self.trim_left(amount,margin)
224
self.trim_bottom(amount,margin)
226
def inflate(self,percent):
227
""" Expand the box in all directions by the specified percentage.
228
Values < 1 shrink the box. The center of the box remains the same.
230
old_size = self.size()
231
inflated = old_size * percent
232
shift = .5 * (old_size - inflated)
233
self.topleft = floor(self.topleft + shift).astype(TYPE)
234
self.set_size(floor(inflated).astype(TYPE))
236
#------------------ test point relationships ----------------#
237
def contains(self,pos):
238
l,r,t,b = self.left(),self.right(),self.top(),self.bottom()
239
#print self.text, self.size(), l,r,t,b
241
if ((l <= pos[0] <= r) and (t <= pos[1] <= b)):
245
class point_object(box_object):
246
""" Useful for laying points (and boxes) in relation to one another.
248
point_object is a degenerate case of a box_object that has
249
zero size. The set_size() method is not allowed for points. As
250
a result, size altering methods such as set_height and trimming
251
functions are not valid. Also, the radial() method always returns
252
zero. Other than that, the standard box_object methods work.
253
point_objects can also be used as arguments to box_object methods.
255
def __init__(self,pt):
256
box_object.__init__(self,pt,(0,0))
257
def radial(self,angle):
259
def set_size(self,sz):
260
# this'll catch all set_size, trim_xxx and other inappropriate methods
261
raise TypeError, "can't set the size of a point_object"
263
def bounding_points(objects):
264
l = min(map(lambda x: x.left(),objects))
265
t = min(map(lambda x: x.top(),objects))
266
r = min(map(lambda x: x.right(),objects))
267
b = min(map(lambda x: x.bottom(),objects))
270
def bounding_box(objects):
271
l = min(map(lambda x: x.left(),objects))
272
t = min(map(lambda x: x.top(),objects))
273
r = min(map(lambda x: x.right(),objects))
274
b = min(map(lambda x: x.bottom(),objects))
275
return box_object((l,t),(r-l,b-t))
277
#----------------------------------------------------------
278
# Simple Property class
279
#----------------------------------------------------------
281
class property_object:
282
""" Base class for graph object with properties like 'color', 'font', etc.
284
Many object have a standard set of properties that describe the
285
object. Text objects, for example, have a color, font, and style.
286
These attirbutes often have default values and perhaps only a range
287
of acceptable values. Every class derived from this one must have
288
the variable "_attributes" defined at the class level. The keys of
289
this dictionary are the names of the class attributes. The value
290
for each key is a list with three entries. The first is the default
291
value for the attribute, the second is a list of acceptable values
292
and the third is a short text description of the attribute. FOr
294
class text_object(property_class):
295
_attributes = { 'color': ['black',['black','red','blue'],
296
"The color of the text" ],
297
'style': ['normal',['normal','bold'],
298
"The style of the text" ],}
300
Currently only the first entry is used. The second is rather limited
301
in functionality, but might be useful for automatically generating
304
Graphite has a more flexible property system, but it is somewhat
305
complex. Zope also has a nice property structure. If we run into
306
major limitations with this 15 line implementation, we might look
307
here for inspiration (or adoption...).
309
There is no type safety enforced here which could cause so problems
310
for unsuspecting users. Maybe the optional typing of future Python
311
versions will mitigate this.
313
def __init__(self, kw, **attr):
314
attrs = {};attrs.update(kw);
315
if attr: attrs.update(attr)
316
for name, value in self._attributes.items():
320
except KeyError: pass
321
self.__dict__[name] = val
322
def reset_default(self):
323
""" Reset the objects attributes to their default values.
325
for name, value in self._attributes.items():
326
self.__dict__[name] = value[0]
328
def clone_properties(self,other):
329
""" Reset the objects attributes to their default values.
331
for name in other._attributes.keys():
332
self.__dict__[name] = other.__dict__[name]
334
#----------------------------------------------------------#
335
#-------------- Axis and Tick mark utilities --------------#
336
#----------------------------------------------------------#
338
def rotate(pts,center,angle):
339
""" pts is a Nx2 array of N (x,y) point pairs. The points
340
are rotated counter-clockwise around a center (x,y) point
341
the specified angle (in radians).
343
t_pts = asarray(pts) - asarray(center)
344
transform = array( ((cos(angle),-sin(angle)),
345
(sin(angle), cos(angle))) )
346
rotated = transpose(dot(transform, transpose(t_pts)))
347
t_pts = around(rotated + center)
348
return t_pts.astype(TYPE)
351
""" Log base 2 of a number ( or array)
353
!! 1e-16 is here to prevent errors when log is 0
355
if is_number(num) and num == 0:
357
elif type(num) == type(array([])):
358
putmask(num,equal(num,0.),1e-16)
359
return log10(num)/log10(2)
362
" True if value is base 2 (2, 4, 8, 16, ...)"
364
return (l == floor(l) and l > 0.0)
367
" Returns 1 if value is an integer or double, 0 otherwise."
368
return (type(val) in [type(0.0),type(0)])
370
default_bounds = ['auto','auto','auto']
371
def auto_ticks(data_bounds, bounds_info = default_bounds):
372
""" Find locations for axis tick marks.
374
Calculate the location for tick marks on an axis. data_bounds is a
375
sequence of 2 numbers specifying the maximum and minimum values of
376
the data along this axis. bounds_info is a sequence of 3 values that
377
specify how the axis end points and tick interval are calculated. An
378
array of tick mark locations is returned from the function. The
379
first and last tick entries are the axis end points.
381
data_bounds -- (lower,upper). The maximum and minimum values of the
382
data long this axis. If any of the settings in
383
bounds_info are 'auto' or 'fit', the axis properties
384
are calculated automatically from these settings.
385
bounds_info -- (lower,upper,interval). Each entry can either be
386
a numerical value or a string. If a number,the axis
387
property is set to that value. If the entry
388
is 'auto', the property is calculated automatically.
389
lower and upper can also be 'fit' in which case
390
the axis end points are set equal to the values
393
# pretty ugly code...
394
# man, this needs some testing.
396
# hmmm, all the nan stuff should have been handled before this
398
#from misc import nan_to_num, isinf
399
#if is_number(bounds_info[0]): lower = nan_to_num(bounds_info[0])
400
#else: lower = nan_to_num(data_bounds[0])
401
#if is_number(bounds_info[1]): upper = nan_to_num(bounds_info[1])
402
#else: upper = nan_to_num(data_bounds[1])
403
#if is_number(bounds_info[2]): interval = nan_to_num(bounds_info[2])
404
#else: interval = bounds_info[2]
406
if is_number(bounds_info[0]): lower = bounds_info[0]
407
else: lower = data_bounds[0]
408
if is_number(bounds_info[1]): upper = bounds_info[1]
409
else: upper = data_bounds[1]
410
interval = bounds_info[2]
412
#print 'raw input:', lower,upper,interval
413
#print 'raw interval:', interval
414
if interval in ['linear','auto']:
415
rng = abs(upper - lower)
417
# anything more intelligent to do here?
419
lower,upper = data_bounds + array((-.5,.5))
420
if is_base2(rng) and is_base2(upper) and rng > 4:
426
interval = rng / 4 # maybe we want it 8
428
interval = auto_interval((lower,upper))
429
elif type(interval) in [type(0.0),type(0)]:
432
#print 'interval: ', interval
433
raise ValueError, interval + " is an unknown value for interval: " \
434
" expects 'auto' or 'linear', or a number"
436
# If the lower or upper bound are set to 'auto',
437
# calculate them based on the newly chosen interval.
438
#print 'interval:', interval
439
auto_lower,auto_upper = auto_bounds(data_bounds,interval)
440
if bounds_info[0] == 'auto':
442
if bounds_info[1] == 'auto':
445
# again, we shouldn't need this
446
# cluge to handle inf values
448
# lower = nan_to_num(lower) / 10
450
# upper = nan_to_num(upper) / 10
452
# interval = nan_to_num(interval) / 10
453
# if the lower and upper bound span 0, make sure ticks
454
# will hit exactly on zero.
456
if lower < 0 and upper > 0:
457
#print 'arrrgh',0,upper+interval,interval
458
hi_ticks = arange(0,upper+interval,interval)
459
low_ticks = - arange(interval,-lower+interval,interval)
460
ticks = concatenate((low_ticks[::-1],hi_ticks))
462
# othersize the ticks start and end on the lower and
464
ticks = arange(lower,upper+interval,interval)
467
ticks = array(((lower-lower*1e-7),lower))
468
#print 'ticks:',ticks
469
if bounds_info[0] == 'fit': ticks[0] = lower
470
if bounds_info[1] == 'fit': ticks[-1] = upper
474
def format_tick_labels(ticks):
475
""" Convert tick values to formatted strings.
476
Definitely needs some work
478
return map(str,ticks)
480
#if equal(ticks,ticks.astype('l')):
481
# return map(str,ticks.astype('l'))
483
# return map(str,ticks)
487
def calc_bound(end_point,interval,end):
488
""" Find an axis end point that includes the the value end_point. If the
489
tick mark interval results in a tick mark hitting directly on the
490
end_point, end_point is returned. Otherwise, the location of the tick
491
mark just past the end_point is returned. end is 'lower' or 'upper' to
492
specify whether end_point is at the lower or upper end of the axis.
494
quotient,remainder = divmod(end_point,interval)
495
if not remainder: return end_point
497
c1 = axis_bound = (quotient + 1) * interval
498
c2 = axis_bound = (quotient) * interval
499
if end == 'upper': return max(c1,c2)
500
if end == 'lower': return min(c1,c2)
503
def auto_bounds(data_bounds,interval):
504
""" Calculate an appropriate upper and lower bounds for the axis from
505
the the data_bounds (lower, upper) and the given axis interval. The
506
boundaries will either hit exactly on the lower and upper values
507
or on the tick mark just beyond the lower and upper values.
509
data_lower,data_upper = data_bounds
510
lower = calc_bound(data_lower,interval,'lower')
511
upper = calc_bound(data_upper,interval,'upper')
512
return array((lower,upper))
514
def auto_interval(data_bounds):
515
""" Calculate the tick intervals for a graph axis.
518
The boundaries for the data to be plotted on the axis are:
519
data_bounds = (lower,upper)
521
A choice is made between 3 to 9 ticks marks (including end points)
522
and tick intervals at 1, 2, 2.5, 5, 10, 20, ...
525
interval -- float. tick mark interval for axis
527
range = float(data_bounds[1]) - float(data_bounds[0])
528
# We'll choose from between 2 and 8 tick marks
529
# Favortism is given to more ticks:
530
# Note reverse order and see CLUGE below...
531
divisions = arange(8,2.,-1.) #(7,6,...,3)
532
# Calculate the intervals for the divisions
533
candidate_intervals = range / divisions
534
# Get magnitudes and mantissas for each candidate
535
magnitudes = 10.**floor(log10(candidate_intervals))
536
mantissas = candidate_intervals / magnitudes
538
# list of "pleasing" intervals between ticks on graph
539
# only first magnitude listed, higher mags others inferred
540
magic_intervals = array((1.,2.,2.5,5.,10.))
541
# calculate the absolute differences between the candidates
542
# (with mag removed) and the magic intervals
543
differences = abs(magic_intervals[:,newaxis] - mantissas)
545
# Find the division and magic interval combo
546
# that produce the smallest differences.
548
# CLUGE: argsort doesn't preserve the order of
549
# equal values, so we subtract a small , index
550
# dependent amount from each difference to
551
# force correct ordering
552
sh = shape(differences)
553
small = 2.2e-16 *arange(sh[1]) * arange(sh[0])[:,newaxis]
554
small = small[::-1,::-1] #reverse order
555
differences = differences - small
556
# ? Numeric should allow keyword "axis" ? comment out for now
557
#best_mantissa = minimum.reduce(differences,axis=0)
558
#best_magic = minimum.reduce(differences,axis=-1)
559
best_mantissa = minimum.reduce(differences,0)
560
best_magic = minimum.reduce(differences,-1)
561
magic_index = argsort(best_magic)[0]
562
mantissa_index = argsort(best_mantissa)[0]
563
# The best interval is the magic_interval
564
# multiplied by the magnitude of the best mantissa
565
interval = magic_intervals[magic_index]
566
magnitude = magnitudes[mantissa_index]
568
#print 'results:', magic_index, mantissa_index,interval, magnitude
569
#print 'returned:',interval*magnitude
570
result = interval*magnitude
572
result = limits.float_epsilon