2
Functions and classes that compute ticks and labels for graph axes, with
3
special handling of time and calendar axes.
6
from bisect import bisect
7
from math import ceil, floor, log10
8
from numpy import abs, argmin, array, isnan, linspace
11
from formatters import BasicFormatter
14
__all__ = ["AbstractScale", "DefaultScale", "FixedScale", "Pow10Scale",
15
"LogScale", "ScaleSystem", "heckbert_interval", "frange"]
17
def frange(min, max, delta):
18
""" Floating point range. """
19
count = int(round((max - min) / delta)) + 1
20
return [min + i*delta for i in range(count)]
22
class AbstractScale(object):
23
""" Defines the general interface for scales. """
27
def ticks(self, start, end, desired_ticks=None):
28
""" Returns the set of "nice" positions on this scale that enclose and
29
fall inside the interval (*start*,*end*).
34
The beginning of the scale interval.
36
The end of the scale interval.
37
desired_ticks : integer
38
Number of ticks that the caller would like to get
41
raise NotImplementedError
43
def num_ticks(self, start, end, desired_ticks=None):
44
""" Returns an approximate number of ticks that this scale
45
produces for the given interval.
47
This method is used by the scale system to determine whether this is
48
the appropriate scale to use for an interval; the returned number of
49
ticks does not have to be exactly the same as what ticks() returns.
54
The beginning of the scale interval.
56
The end of the scale interval.
57
desired_ticks : integer
58
Number of ticks that the caller would like to get
62
A float or an integer.
64
raise NotImplementedError
66
def labels(self, start, end, numlabels=None, char_width=None):
67
""" Returns a series of ticks and corresponding strings for labels
68
that fall inside the interval (*start*,*end*).
73
The beginning of the scale interval.
75
The end of the scale interval.
77
The ideal number of labels to generate on the interval.
79
The total character width available for labelling the interval.
81
One of *numlabels* or *char_width* must be provided. If both are
82
provided, then both are considered when picking label density and format.
84
ticks = self.ticks(start, end, numlabels)
85
labels = self.formatter.format(ticks, numlabels, char_width)
86
return zip(ticks, labels)
88
def label_width(self, start, end, numlabels=None, char_width=None):
89
""" Returns an estimate of the total number of characters used by the
90
the labels that this scale produces for the given set of
91
inputs, as well as the number of labels.
96
The beginning of the scale interval.
98
The end of the scale interval.
100
The ideal number of labels to generate on the interval.
102
The total character width available for labelling the interval.
106
(numlabels, total label width)
108
return self.formatter.estimate_width(start, end, numlabels, char_width,
112
class FixedScale(AbstractScale):
113
""" A scale with fixed resolution, and "nice" points that line up at
114
multiples of the resolution. An optional zero value can be defined
115
that offsets the "nice" points to (N*resolution+zero).
117
def __init__(self, resolution, zero=0.0, formatter=None):
118
self.resolution = resolution
120
if formatter is None:
121
formatter = BasicFormatter()
122
self.formatter = formatter
124
def ticks(self, start, end, desired_ticks=None):
125
""" For FixedScale, *desired_ticks* is ignored.
127
Overrides AbstractScale.
129
if start == end or isnan(start) or isnan(end):
131
res = self.resolution
134
start_tick = int(ceil(start / res))
135
end_tick = int(floor(end / res))
136
ticks = [i*res for i in range(start_tick, end_tick+1)]
139
def num_ticks(self, start, end, desired_ticks=None):
140
""" For FixedScale, *desired_ticks* is ignored.
142
Overrides AbstractScale.
144
if self.resolution is None or self.resolution == 0.0:
147
return (end - start) / self.resolution
149
def _nice(x, round=False):
150
""" Returns a bracketing interval around interval *x*, whose endpoints fall
151
on "nice" values. If *round* is False, then it uses ceil(range)
153
This function is adapted from the original in Graphics Gems; the boundaries
154
have been changed to use (1, 2.5, 5, 10) as the nice values instead of
158
expv = floor(log10(x))
159
f = x / pow(10, expv)
178
return nf * pow(10, expv)
180
def heckbert_interval(data_low, data_high, numticks=8, nicefunc=_nice, enclose=False):
181
""" Returns a "nice" range and resolution for an interval and a preferred
182
number of ticks, using Paul Heckbert's algorithm in Graphics Gems.
184
If *enclose* is True, then the function returns a min and a max that fall
185
inside *data_low* and *data_high*; if *enclose* is False, the nice interval
186
can be larger than the input interval.
188
if data_high == data_low:
189
return data_high, data_low, 0
193
range = nicefunc(data_high - data_low)
196
d = nicefunc(range / numticks, round=True)
198
graphmin = ceil(data_low / d) * d
199
graphmax = floor(data_high / d) * d
201
graphmin = floor(data_low / d) * d
202
graphmax = ceil(data_high / d) * d
203
return graphmin, graphmax, d
206
class DefaultScale(AbstractScale):
207
""" A dynamic scale that tries to place ticks at nice numbers (1, 2, 5, 10)
208
so that ticks don't "pop" as the resolution changes.
210
def __init__(self, formatter=None):
211
if formatter is None:
212
formatter = BasicFormatter()
213
self.formatter = formatter
215
def ticks(self, start, end, desired_ticks=8):
216
""" Returns the set of "nice" positions on this scale that enclose and
217
fall inside the interval (*start*,*end*).
219
Implements AbstractScale.
221
if start == end or isnan(start) or isnan(end):
223
min, max, delta = heckbert_interval(start, end, desired_ticks, enclose=True)
224
return frange(min, max, delta)
226
def num_ticks(self, start, end, desired_ticks=8):
227
""" Returns an approximate number of ticks that this scale
228
produces for the given interval.
230
Implements AbstractScale.
232
return len(self.ticks(start, end, desired_ticks))
235
class Pow10Scale(AbstractScale):
236
""" A dynamic scale that shows only whole multiples of powers of 10
237
(including powers < 1).
240
def __init__(self, formatter=None):
241
if formatter is None:
242
formatter = BasicFormatter()
243
self.formatter = formatter
245
def ticks(self, start, end, desired_ticks=8):
246
""" Returns the set of "nice" positions on this scale that enclose and
247
fall inside the interval (*start*,*end*).
249
Implements AbstractScale.
251
if start == end or isnan(start) or isnan(end):
253
min, max, delta = heckbert_interval(start, end, desired_ticks,
254
nicefunc=self._nice_pow10,
256
return frange(min, max, delta)
258
def num_ticks(self, start, end, desired_ticks=8):
259
""" Returns an approximate number of ticks that this scale
260
produces for the given interval.
262
Implements AbstractScale.
264
return len(self.ticks(start, end, desired_ticks))
266
def _nice_pow10(self, x, round=False):
267
return pow(10, floor(log10(x)))
270
class LogScale(AbstractScale):
271
""" A dynamic scale that only produces ticks and labels that work well when
272
plotting data on a logarithmic scale.
274
def __init__(self, formatter=None):
275
if formatter is None:
276
formatter = BasicFormatter()
277
self.formatter = formatter
279
def ticks(self, start, end, desired_ticks=8):
281
magic_numbers = [1, 2, 5]
284
start, end = end, start
287
# Whoever calls us with a value of 0.0 puts themselves at our mercy
290
log_start = log10(start)
296
log_interval = log_end - log_start
298
if log_interval < 1.0:
299
# If the data is spaced by less than a factor of 10, then use
300
# regular/linear ticking
301
min, max, delta = heckbert_interval(start, end, desired_ticks)
302
return frange(min, max, delta)
304
elif log_interval < desired_ticks:
305
for interval in magic_numbers:
307
for exp in range(int(floor(log_start)), int(ceil(log_end))):
308
for multiplier in linspace(interval, 10.0, round(10.0/interval),
310
tick = 10**exp * multiplier
311
if start <= tick <= end:
312
ticklist.append(tick)
313
if len(ticklist) < desired_ticks * 1.5:
318
# Put lines at every power of ten
319
startlog = ceil(log_start)
320
endlog = floor(log_end)
321
expticks = linspace(startlog, endlog, endlog - startlog + 1)
324
def num_ticks(self, start, end, desired_ticks=8):
325
""" Returns an approximate number of ticks that this scale
326
produces for the given interval.
328
Implements AbstractScale.
330
return len(self.ticks(start, end, desired_ticks))
332
##############################################################################
336
##############################################################################
338
class ScaleSystem(object):
339
""" Represents a collection of scales over some range of resolutions.
341
This class has settings for a default scale that is used when ticking an
342
interval that is smaller than the finest resolution scale or larger than
343
the coarsest resolution scale.
346
def __init__(self, *scales, **kw):
347
""" Creates a ScaleSystem
351
ScaleSystem(scale1, .., scaleN, default_scale = DefaultScale())
353
If *default_scale* is not specified, then an instance of DefaultScale()
354
is created. If no *default_scale* is needed, then set it to None.
357
self.default_scale = kw.get("default_scale", DefaultScale())
359
# Heuristics for picking labels
360
# The ratio of total label character count to the available character width
361
self.fill_ratio = 0.3
362
self.default_numticks = 8
365
def ticks(self, start, end, numticks=None):
366
""" Computes nice locations for tick marks.
371
The start and end values of the data.
373
The desired number of ticks to produce.
374
scales : a list of tuples of (min_interval, Scale)
375
Scales to use, in order from fine resolution to coarse.
376
If the end-start interval is less than a particular scale's
377
*min_interval*, then the previous scale is used.
381
A list of positions where the ticks are to be placed.
386
elif start == end or isnan(start) or isnan(end):
388
elif numticks is None:
389
numticks = self.default_numticks
391
scale = self._get_scale(start, end, numticks)
392
ticks = scale.ticks(start, end, numticks)
395
def labels(self, start, end, numlabels=None, char_width=None):
396
""" Computes position and labels for an interval
401
The beginning of the scale interval.
403
The end of the scale interval.
405
The ideal number of labels to generate on the interval.
407
The total character width available for labelling the interval.
409
One of *numlabels* or *char_width* must be provided. If both are
410
provided, then both are considered when picking label density and format.
414
A list of (tick position, string) tuples.
416
if numlabels == 0 or char_width == 0 or isnan(start) or isnan(end):
419
# There are three cases:
420
# 1. we are given numlabels but not char_width
421
# 2. we are given char_width and not numlabels
422
# 3. we are given both
424
# Case 1: Use numlabels to find the closest scale purely on tick count.
425
# Case 2: Query all scales for their approximate label_width, pick the
426
# closest one to char_width * self.fill_ratio
427
# Case 3: Use numlabels to find the closest scale based on tick count.
429
if numlabels and not char_width:
430
scale = self._get_scale(start, end, numlabels)
431
labels = scale.labels(start, end, numlabels)
435
scale = self._get_scale(start, end, numlabels)
437
ndx = list(self.scales).index(scale)
438
low = max(0, ndx - 1)
439
high = min(len(self.scales), ndx + 1)
440
scales = self.scales[low:high]
444
if len(self.scales) == 0:
445
scales = [self.default_scale]
449
counts, widths = zip(*[s.label_width(start, end, char_width=char_width) \
451
widths = array(widths)
452
closest = argmin(abs(widths - char_width*self.fill_ratio))
453
if numlabels is None:
454
numlabels = scales[closest].num_ticks(start, end, counts[closest])
455
labels = scales[closest].labels(start, end, numlabels,
456
char_width=char_width)
461
def _get_scale(self, start, end, numticks):
462
if len(self.scales) == 0:
463
closest_scale = self.default_scale
465
closest_scale = self._get_scale_np(start, end, numticks)
467
if self.default_scale is not None:
468
# Handle the edge cases and see if there is a major discrepancy between
469
# what the scales offer and the desired number of ticks; if so, revert
470
# to using the default scale
471
approx_ticks = closest_scale.num_ticks(start, end, numticks)
472
if (approx_ticks == 0) or (numticks == 0) or \
473
(abs(approx_ticks - numticks) / numticks > 1.2) or \
474
(abs(numticks - approx_ticks) / approx_ticks > 1.2):
475
closest_scale = self.default_scale
478
def _get_scale_bisect(self, start, end, numticks):
479
scale_intervals = [s.num_ticks(start, end, numticks) for s in self.scales]
480
sorted_scales = sorted(zip(scale_intervals, self.scales))
481
ndx = bisect(sorted_scales, numticks, lo=0, hi=len(self.scales))
482
if ndx == len(self.scales):
484
return sorted_scales[ndx][1]
486
def _get_scale_np(self, start, end, numticks):
487
# Extract the intervals from the scales we were given
488
scale_intervals = array([s.num_ticks(start, end, numticks) for s in self.scales])
489
closest = argmin(abs(scale_intervals - numticks))
490
return self.scales[closest]