~ubuntu-branches/ubuntu/precise/python-chaco/precise

« back to all changes in this revision

Viewing changes to enthought/chaco/scales/scales.py

  • Committer: Bazaar Package Importer
  • Author(s): Varun Hiremath
  • Date: 2008-12-29 02:34:05 UTC
  • Revision ID: james.westby@ubuntu.com-20081229023405-x7i4kp9mdxzmdnvu
Tags: upstream-3.0.1
ImportĀ upstreamĀ versionĀ 3.0.1

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
"""
 
2
Functions and classes that compute ticks and labels for graph axes, with 
 
3
special handling of time and calendar axes.
 
4
"""
 
5
 
 
6
from bisect import bisect
 
7
from math import ceil, floor, log10
 
8
from numpy import abs, argmin, array, isnan, linspace
 
9
 
 
10
# Local imports
 
11
from formatters import BasicFormatter
 
12
 
 
13
 
 
14
__all__ = ["AbstractScale", "DefaultScale", "FixedScale", "Pow10Scale",
 
15
           "LogScale", "ScaleSystem", "heckbert_interval", "frange"]
 
16
 
 
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)]
 
21
 
 
22
class AbstractScale(object):
 
23
    """ Defines the general interface for scales. """
 
24
 
 
25
    DEFAULT_NUM_TICKS = 8
 
26
 
 
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*). 
 
30
        
 
31
        Parameters
 
32
        ----------
 
33
        start : number
 
34
            The beginning of the scale interval.
 
35
        end : number
 
36
            The end of the scale interval.
 
37
        desired_ticks : integer
 
38
            Number of ticks that the caller would like to get
 
39
        
 
40
        """
 
41
        raise NotImplementedError
 
42
 
 
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.  
 
46
        
 
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.
 
50
 
 
51
        Parameters
 
52
        ----------
 
53
        start : number
 
54
            The beginning of the scale interval.
 
55
        end : number
 
56
            The end of the scale interval.
 
57
        desired_ticks : integer
 
58
            Number of ticks that the caller would like to get
 
59
        
 
60
        Returns
 
61
        -------
 
62
        A float or an integer.
 
63
        """
 
64
        raise NotImplementedError
 
65
 
 
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*).
 
69
 
 
70
        Parameters
 
71
        ----------
 
72
        start : number
 
73
            The beginning of the scale interval.
 
74
        end : number
 
75
            The end of the scale interval.
 
76
        numlabels : number
 
77
            The ideal number of labels to generate on the interval. 
 
78
        char_width : number
 
79
            The total character width available for labelling the interval.  
 
80
            
 
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.
 
83
        """
 
84
        ticks = self.ticks(start, end, numlabels)
 
85
        labels = self.formatter.format(ticks, numlabels, char_width)
 
86
        return zip(ticks, labels)
 
87
 
 
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.
 
92
        
 
93
        Parameters
 
94
        ----------
 
95
        start : number
 
96
            The beginning of the scale interval.
 
97
        end : number
 
98
            The end of the scale interval.
 
99
        numlabels : number
 
100
            The ideal number of labels to generate on the interval. 
 
101
        char_width : number
 
102
            The total character width available for labelling the interval.  
 
103
 
 
104
        Returns
 
105
        -------
 
106
        (numlabels, total label width)
 
107
        """
 
108
        return self.formatter.estimate_width(start, end, numlabels, char_width,
 
109
                                             ticker=self)
 
110
 
 
111
        
 
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).
 
116
    """
 
117
    def __init__(self, resolution, zero=0.0, formatter=None):
 
118
        self.resolution = resolution
 
119
        self.zero = zero
 
120
        if formatter is None:
 
121
            formatter = BasicFormatter()
 
122
        self.formatter = formatter
 
123
        
 
124
    def ticks(self, start, end, desired_ticks=None):
 
125
        """ For FixedScale, *desired_ticks* is ignored. 
 
126
        
 
127
        Overrides AbstractScale.
 
128
        """
 
129
        if start == end or isnan(start) or isnan(end):
 
130
            return []
 
131
        res = self.resolution
 
132
        start -= self.zero
 
133
        end -= self.zero
 
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)]
 
137
        return ticks
 
138
 
 
139
    def num_ticks(self, start, end, desired_ticks=None):
 
140
        """ For FixedScale, *desired_ticks* is ignored. 
 
141
        
 
142
        Overrides AbstractScale.
 
143
        """
 
144
        if self.resolution is None or self.resolution == 0.0:
 
145
            return 0
 
146
        else:
 
147
            return (end - start) / self.resolution
 
148
 
 
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)
 
152
    
 
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
 
155
    (1, 2, 5, 10).
 
156
    """
 
157
 
 
158
    expv = floor(log10(x))
 
159
    f = x / pow(10, expv)
 
160
    if round:
 
161
        if f < 1.75:
 
162
            nf = 1.0
 
163
        elif f < 3.75:
 
164
            nf = 2.5
 
165
        elif f < 7.0:
 
166
            nf = 5.0;
 
167
        else:
 
168
            nf = 10.0
 
169
    else:
 
170
        if f <= 1.0:
 
171
            nf = 1.0
 
172
        elif f <= 2.5:
 
173
            nf = 2.5
 
174
        elif f <= 5.0:
 
175
            nf = 5.0
 
176
        else:
 
177
            nf = 10.0
 
178
    return nf * pow(10, expv)
 
179
 
 
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.
 
183
 
 
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.
 
187
    """
 
188
    if data_high == data_low:
 
189
        return data_high, data_low, 0
 
190
    if numticks == 0:
 
191
        numticks = 1
 
192
 
 
193
    range = nicefunc(data_high - data_low)
 
194
    if numticks > 1:
 
195
        numticks -= 1
 
196
    d = nicefunc(range / numticks, round=True)
 
197
    if enclose:
 
198
        graphmin = ceil(data_low / d) * d
 
199
        graphmax = floor(data_high / d) * d
 
200
    else:
 
201
        graphmin = floor(data_low / d) * d
 
202
        graphmax = ceil(data_high / d) * d
 
203
    return graphmin, graphmax, d
 
204
 
 
205
 
 
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.
 
209
    """
 
210
    def __init__(self, formatter=None):
 
211
        if formatter is None:
 
212
            formatter = BasicFormatter()
 
213
        self.formatter = formatter
 
214
        
 
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*). 
 
218
        
 
219
        Implements AbstractScale.
 
220
        """
 
221
        if start == end or isnan(start) or isnan(end):
 
222
            return [start]
 
223
        min, max, delta = heckbert_interval(start, end, desired_ticks, enclose=True)
 
224
        return frange(min, max, delta)
 
225
 
 
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.  
 
229
        
 
230
        Implements AbstractScale.
 
231
        """
 
232
        return len(self.ticks(start, end, desired_ticks))
 
233
 
 
234
 
 
235
class Pow10Scale(AbstractScale):
 
236
    """ A dynamic scale that shows only whole multiples of powers of 10 
 
237
    (including powers < 1).
 
238
    """
 
239
 
 
240
    def __init__(self, formatter=None):
 
241
        if formatter is None:
 
242
            formatter = BasicFormatter()
 
243
        self.formatter = formatter
 
244
 
 
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*). 
 
248
 
 
249
        Implements AbstractScale.
 
250
        """
 
251
        if start == end or isnan(start) or isnan(end):
 
252
            return [start]
 
253
        min, max, delta = heckbert_interval(start, end, desired_ticks,
 
254
                                            nicefunc=self._nice_pow10,
 
255
                                            enclose = True)
 
256
        return frange(min, max, delta)
 
257
    
 
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.  
 
261
        
 
262
        Implements AbstractScale.
 
263
        """
 
264
        return len(self.ticks(start, end, desired_ticks))
 
265
 
 
266
    def _nice_pow10(self, x, round=False):
 
267
        return pow(10, floor(log10(x)))
 
268
 
 
269
 
 
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.
 
273
    """
 
274
    def __init__(self, formatter=None):
 
275
        if formatter is None:
 
276
            formatter = BasicFormatter()
 
277
        self.formatter = formatter
 
278
 
 
279
    def ticks(self, start, end, desired_ticks=8):
 
280
 
 
281
        magic_numbers = [1, 2, 5]
 
282
        
 
283
        if start > end:
 
284
            start, end = end, start
 
285
 
 
286
        if start == 0.0:
 
287
            # Whoever calls us with a value of 0.0 puts themselves at our mercy
 
288
            log_start = 1e-9
 
289
        else:
 
290
            log_start = log10(start)
 
291
 
 
292
        if end == 0.0:
 
293
            log_end = 1e-9
 
294
        else:
 
295
            log_end = log10(end)
 
296
        log_interval = log_end - log_start
 
297
 
 
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)
 
303
 
 
304
        elif log_interval < desired_ticks:
 
305
            for interval in magic_numbers:
 
306
                ticklist = []
 
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),
 
309
                                               endpoint=1):
 
310
                        tick = 10**exp * multiplier
 
311
                        if start <= tick <= end:
 
312
                            ticklist.append(tick)
 
313
                if len(ticklist) < desired_ticks * 1.5:
 
314
                    return ticklist
 
315
            return ticklist
 
316
        
 
317
        else:
 
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)
 
322
            return 10**expticks
 
323
 
 
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.  
 
327
        
 
328
        Implements AbstractScale.
 
329
        """
 
330
        return len(self.ticks(start, end, desired_ticks))
 
331
 
 
332
##############################################################################
 
333
#
 
334
# ScaleSystem
 
335
#
 
336
##############################################################################
 
337
 
 
338
class ScaleSystem(object):
 
339
    """ Represents a collection of scales over some range of resolutions.  
 
340
    
 
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.
 
344
    """
 
345
 
 
346
    def __init__(self, *scales, **kw):
 
347
        """ Creates a ScaleSystem
 
348
 
 
349
        Usage::
 
350
            
 
351
            ScaleSystem(scale1, .., scaleN, default_scale = DefaultScale())
 
352
 
 
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.
 
355
        """
 
356
        self.scales = scales
 
357
        self.default_scale = kw.get("default_scale", DefaultScale())
 
358
 
 
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
 
363
 
 
364
 
 
365
    def ticks(self, start, end, numticks=None):
 
366
        """ Computes nice locations for tick marks.
 
367
 
 
368
        Parameters
 
369
        ==========
 
370
        start, end : number
 
371
            The start and end values of the data.
 
372
        numticks : number
 
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.
 
378
 
 
379
        Returns
 
380
        =======
 
381
        A list of positions where the ticks are to be placed.
 
382
        """
 
383
 
 
384
        if numticks == 0:
 
385
            return []
 
386
        elif start == end or isnan(start) or isnan(end):
 
387
            return []
 
388
        elif numticks is None:
 
389
            numticks = self.default_numticks
 
390
 
 
391
        scale = self._get_scale(start, end, numticks)
 
392
        ticks = scale.ticks(start, end, numticks)
 
393
        return ticks
 
394
 
 
395
    def labels(self, start, end, numlabels=None, char_width=None):
 
396
        """ Computes position and labels for an interval
 
397
 
 
398
        Parameters
 
399
        ----------
 
400
        start : number
 
401
            The beginning of the scale interval.
 
402
        end : number
 
403
            The end of the scale interval.
 
404
        numlabels : number
 
405
            The ideal number of labels to generate on the interval. 
 
406
        char_width : number
 
407
            The total character width available for labelling the interval.  
 
408
        
 
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.
 
411
 
 
412
        Returns
 
413
        -------
 
414
        A list of (tick position, string) tuples.
 
415
        """
 
416
        if numlabels == 0 or char_width == 0 or isnan(start) or isnan(end):
 
417
            return []
 
418
 
 
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
 
423
        #
 
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.
 
428
 
 
429
        if numlabels and not char_width:
 
430
            scale = self._get_scale(start, end, numlabels)
 
431
            labels = scale.labels(start, end, numlabels)
 
432
 
 
433
        elif char_width:
 
434
            if numlabels:
 
435
                scale = self._get_scale(start, end, numlabels)
 
436
                try:
 
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]
 
441
                except ValueError:
 
442
                    scales = [scale]
 
443
            else:
 
444
                if len(self.scales) == 0:
 
445
                    scales = [self.default_scale]
 
446
                else:
 
447
                    scales = self.scales
 
448
 
 
449
            counts, widths = zip(*[s.label_width(start, end, char_width=char_width) \
 
450
                                      for s in scales])
 
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)
 
457
 
 
458
        return labels
 
459
            
 
460
 
 
461
    def _get_scale(self, start, end, numticks):
 
462
        if len(self.scales) == 0:
 
463
            closest_scale = self.default_scale
 
464
        else:
 
465
            closest_scale = self._get_scale_np(start, end, numticks)
 
466
 
 
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
 
476
        return closest_scale
 
477
 
 
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):
 
483
            ndx -= 1
 
484
        return sorted_scales[ndx][1]
 
485
 
 
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]
 
491