File Coverage

blib/lib/Chart/Math/Axis.pm
Criterion Covered Total %
statement 128 134 95.5
branch 55 74 74.3
condition 1 3 33.3
subroutine 25 26 96.1
pod 12 12 100.0
total 221 249 88.7


line stmt bran cond sub pod time code
1             package Chart::Math::Axis;
2              
3             # This is a package implements an algorithm for calculating
4             # a good vertical scale for a graph.
5             # That is, given a data set, find a maximum, minimum and interval values
6             # that will provide the most attractive display for a graph.
7              
8 2     2   131495 use 5.005;
  2         9  
  2         89  
9 2     2   11 use strict;
  2         3  
  2         83  
10 2     2   2671 use Storable 2.12 ();
  2         8951  
  2         61  
11 2     2   7229 use Math::BigInt 1.70 ();
  2         29990  
  2         76  
12 2     2   5549 use Math::BigFloat 1.44 (); # Needs bdiv
  2         39699  
  2         153  
13 2     2   3634 use Params::Util 0.15 ();
  2         8682  
  2         70  
14              
15 2     2   16 use vars qw{$VERSION};
  2         4  
  2         109  
16             BEGIN {
17 2     2   3810 $VERSION = '1.06';
18             }
19              
20              
21              
22              
23              
24             ###############################################################################
25             # Public Methods
26              
27             # Constructor is passed a list of values, which should be the entire set of
28             # values on the Y axis.
29             sub new {
30             # Create the object
31 16     16 1 6763 my $self = bless {
32             max => undef, # Data maximum value
33             min => undef, # Data minimum value
34             top => undef, # Top value on the axis
35             bottom => undef, # Bottom value on the axis
36             maximum_intervals => 10, # Maximum number of intervals
37             interval_size => undef, # The interval size
38             }, shift;
39              
40             # If we got some argument
41 16 100       70 if ( @_ ) {
42             # Add the data from the data set.
43             # ->_add_data will trigger the calculation process.
44 15 50       48 $self->add_data( @_ ) or return undef;
45             }
46              
47 16         78 $self;
48             }
49              
50             # Data access methods
51 16     16 1 6903 sub max { $_[0]->{max} }
52 16     16 1 108 sub min { $_[0]->{min} }
53 16     16 1 180 sub top { $_[0]->{top} }
54 16     16 1 141 sub bottom { $_[0]->{bottom} }
55 2     2 1 20 sub maximum_intervals { $_[0]->{maximum_intervals} }
56 16     16 1 122 sub interval_size { $_[0]->{interval_size} }
57              
58             # Return the actual number of ticks that should be needed
59             sub ticks {
60 16     16 1 47 my $self = shift;
61 16 100       71 return undef unless defined $self->{max};
62 15 50       45 return undef unless defined $self->{min};
63 15 50       49 return undef unless defined $self->{interval_size};
64              
65             # Calculate the ticks
66 15         112 return ($self->{top} - $self->{bottom}) / $self->{interval_size};
67             }
68              
69             # Method to force the scale to include the zero line.
70             sub include_zero {
71 3     3 1 29 $_[0]->add_data(0);
72             }
73              
74             # Method to add additional data elements to the data set
75             # The object doesn't need to store all the data elements,
76             # just the maximum and minimum values
77             sub add_data {
78 19     19 1 43 my $self = shift;
79              
80             # Make sure they passed at least one data element
81 19 50       259 return undef unless @_;
82              
83             # Handle the case of when this is the first data
84 19 100       74 $self->{max} = $_[0] unless defined $self->{max};
85 19 100       69 $self->{min} = $_[0] unless defined $self->{min};
86              
87             # Go through and adjust the max and min as needed
88 19         53 foreach ( @_ ) {
89 26 100       76 $self->{max} = $_ if $_ > $self->{max};
90 26 100       93 $self->{min} = $_ if $_ < $self->{min};
91             }
92              
93             # Recalculate
94 19         60 $self->_calculate;
95             }
96              
97             # Change the interval quantity.
98             # Change this to stupid values, and I can't guarantee this module won't break.
99             # It needs to be robuster ( is that a word? )
100             # You will not get the exact number of intervals you specify.
101             # You are only guaranteed not to get MORE than this.
102             # The point being that if your labels take up X width, you
103             # can specify a maximum number of ticks not to exceed.
104             sub set_maximum_intervals {
105 1     1 1 3 my $self = shift;
106 1 50       4 my $quantity = $_[0] > 1 ? shift : return undef;
107              
108             # Set the interval quantity
109 1         3 $self->{maximum_intervals} = $quantity;
110              
111             # Recalculate
112 1         4 $self->_calculate;
113             }
114              
115             # Automatically apply the axis values to objects of different types.
116             # Currently only GD::Graph objects are supported.
117             sub apply_to {
118 0     0 1 0 my $self = shift;
119 0 0       0 unless ( Params::Util::_INSTANCE($_[0], 'GD::Graph::axestype') ) {
120 0         0 die "Tried to apply scale to an unknown graph type";
121             }
122              
123             shift->set(
124 0         0 y_max_value => $self->top,
125             y_min_value => $self->bottom,
126             y_tick_number => $self->ticks,
127             );
128              
129 0         0 1;
130             }
131              
132              
133              
134              
135              
136             ###############################################################################
137             # Private methods
138              
139             # This method implements the main part of the algorithm
140             sub _calculate {
141 20     20   35 my $self = shift;
142              
143             # Max sure we have a maximum, minimum, and interval quantity
144 20 50       52 return undef unless defined $self->{max};
145 20 50       49 return undef unless defined $self->{min};
146 20 50       58 return undef unless defined $self->{maximum_intervals};
147              
148             # Pass off the special max == min case to the dedicated method
149 20 100       69 return $self->_calculate_single if $self->{max} == $self->{min};
150              
151             # Get some math objects for the max and min
152 12         456 my $Maximum = Math::BigFloat->new( $self->{max} );
153 12         1341 my $Minimum = Math::BigFloat->new( $self->{min} );
154 12 50 33     1058 return undef unless (defined $Maximum or defined $Minimum);
155              
156             # Get the magnitude of the numbers
157 12         41 my $max_magnitude = $self->_order_of_magnitude( $Maximum );
158 12         487 my $min_magnitude = $self->_order_of_magnitude( $Minimum );
159              
160             # Find the largest order of magnitude
161 12 100       933 my $magnitude = $max_magnitude > $min_magnitude
162             ? $max_magnitude : $min_magnitude;
163              
164             # Create some starting values based on this
165 12         50 my $Interval = Math::BigFloat->new( 10 ** ($magnitude + 1) );
166 12         1791 my $Top = $self->_round_top( $Maximum, $Interval );
167 12         1300 my $Bottom = $self->_round_bottom( $Minimum, $Interval );
168 12         1445 $self->{top} = $Top->bstr;
169 12         546 $self->{bottom} = $Bottom->bstr;
170 12         318 $self->{interval_size} = $Interval->bstr;
171              
172             # Loop as we tighten the integer until the correct number of
173             # intervals are found.
174 12         448 my $loop = 0;
175 12         19 while ( 1 ) {
176             # Descend to the next interval
177 64 50       171 my $NextInterval = $self->_reduce_interval( $Interval ) or return undef;
178              
179             # Get the rounded values for the maximum and minimum
180 64         32349 $Top = $self->_round_top( $Maximum, $NextInterval );
181 64         7558 $Bottom = $self->_round_bottom( $Minimum, $NextInterval );
182              
183             # How many intervals fit into this range?
184 64         9031 my $NextIntervalQuantity = ( $Top - $Bottom ) / $NextInterval;
185              
186             # If the number of intervals for the next interval is higher
187             # then the maximum number of allowed intervals, use the
188             # current interval
189 64 100       30623 if ( $NextIntervalQuantity > $self->{maximum_intervals} ) {
190             # Finished, return.
191 12         3599 return 1;
192             }
193              
194             # Set the Interval to the next interval
195 52         21690 $Interval = $NextInterval;
196 52         246 $self->{top} = $Top->bstr;
197 52         4715 $self->{bottom} = $Bottom->bstr;
198 52         1259 $self->{interval_size} = $Interval->bstr;
199              
200             # Infinite loop protection
201 52 50       2001 return undef if ++$loop > 100;
202             }
203             }
204              
205             # Handle special calculation case of max == min
206             sub _calculate_single {
207 8     8   16 my $self = shift;
208              
209             # Max sure we have a maximum, minimum, and interval quantity
210 8 50       27 return undef unless defined $self->{max};
211 8 50       23 return undef unless defined $self->{min};
212 8 50       23 return undef unless defined $self->{maximum_intervals};
213              
214             # Handle the super special case of one value of zero
215 8 100       26 if ( $self->{max} == 0 ) {
216 1         3 $self->{top} = 1;
217 1         3 $self->{bottom} = 0;
218 1         2 $self->{interval_size} = 1;
219 1         6 return 1;
220             }
221              
222             # When we only have one value ( that's not zero ), we can get
223             # a top and bottom by rounding up and down at the value's order of magnitude
224 7         45 my $Value = Math::BigFloat->new( $self->{max} );
225 7         1203 my $magnitude = $self->_order_of_magnitude( $Value );
226 7         185 my $Interval = Math::BigFloat->new( 10 ** $magnitude );
227 7         790 $self->{top} = $self->_round_top( $Value, $Interval )->bstr;
228 7         1264 $self->{bottom} = $self->_round_bottom( $Value, $Interval )->bstr;
229 7         1008 $self->{interval_size} = $Interval->bstr;
230              
231             # Tighten the same way we do in the normal _calculate method
232             # but don't recalculate the top and bottom
233             # Loop as we tighten the integer until the correct number of
234             # intervals are found.
235 7         263 my $loop = 0;
236 7         14 while ( 1 ) {
237             # Descend to the next interval
238 23 50       63 my $NextInterval = $self->_reduce_interval( $Interval ) or return undef;
239 23         9658 my $NextIntervalQuantity = ( $self->{top} - $self->{bottom} ) / $NextInterval;
240              
241 23 100       12652 if ( $NextIntervalQuantity > $self->{maximum_intervals} ) {
242             # Finished, return.
243 7         1936 return 1;
244             }
245              
246             # Set the Interval to the next interval
247 16         4988 $Interval = $NextInterval;
248 16         76 $self->{interval_size} = $Interval->bstr;
249              
250             # Infinite loop protection
251 16 50       1014 return undef if ++$loop > 100;
252             }
253             }
254              
255             # For a given interval, work out what the next one down should be
256             sub _reduce_interval {
257 98     98   11799 my $class = shift;
258 98 100       696 my $Interval = Params::Util::_INSTANCE($_[0], 'Math::BigFloat')
259             ? Storable::dclone( shift ) # Don't modify the original
260             : Math::BigFloat->new( shift );
261              
262             # If the mantissa is 5, reduce it to 2
263 98 100       13361 if ( $Interval->mantissa == 5 ) {
    100          
    50          
264 32         4039 return $Interval * (2 / 5);
265              
266             # If the mantissa is 2, reduce it to 1
267             } elsif ( $Interval->mantissa == 2 ) {
268 31         7918 return $Interval * (1 / 2);
269              
270             # If the mantissa is 1, make it 5 and subtract one from the exponent
271             } elsif ( $Interval->mantissa == 1 ) {
272 35         13212 return $Interval * (5 / 10);
273              
274             } else {
275             # We got a problem here.
276             # This is not a value we should expect.
277 0         0 return undef;
278             }
279             }
280              
281             # Find the order of magnitude for a BigFloat.
282             # Not the same as exponent.
283             sub _order_of_magnitude {
284 44     44   5850 my $class = shift;
285 44 100       267 my $BigFloat = Params::Util::_INSTANCE($_[0], 'Math::BigFloat')
286             ? Storable::dclone( shift ) # Don't modify the original
287             : Math::BigFloat->new( shift );
288              
289             # Zero is special, and won't work with the math below
290 44 100       4199 return 0 if $BigFloat == 0;
291            
292             # Calculate the ordinality
293 35         5881 my $Ordinality = $BigFloat->mantissa->length
294             + $BigFloat->exponent - 1;
295            
296             # Return it as a normal perl int
297 35         15047 $Ordinality->bstr;
298             }
299              
300             # Two rounding methods to handle the special rounding cases we need
301             sub _round_top {
302 107     107   26614 my $class = shift;
303 107 100       559 my $Number = Params::Util::_INSTANCE($_[0], 'Math::BigFloat')
304             ? Storable::dclone( shift ) # Don't modify the original
305             : Math::BigFloat->new( shift );
306 107         8993 my $Interval = shift;
307              
308             # Round up, or go one interval higher if exact
309 107         347 $Number = $Number->bdiv( $Interval ); # Divide
310 107         38692 $Number = $Number->bfloor->binc; # Round down and add 1
311 107         14853 $Number = $Number * $Interval; # Re-multiply
312             }
313              
314             sub _round_bottom {
315 107     107   26918 my $class = shift;
316 107 100       564 my $Number = Params::Util::_INSTANCE($_[0], 'Math::BigFloat')
317             ? Storable::dclone( shift ) # Don't modify the original
318             : Math::BigFloat->new( shift );
319 107         8377 my $Interval = shift;
320              
321             # In the special case the number is zero, don't round down.
322             # If the graph is already anchored to zero at the bottom, we
323             # don't want to show down to -1 * $Interval.
324 107 100       322 return $Number if $Number == 0;
325              
326             # Round down, or go one interval lower if exact.
327 67         9238 $Number = $Number->bdiv( $Interval ); # Divide
328 67         30280 $Number = $Number->bceil->bdec; # Round up and subtract 1
329 67         8676 $Number = $Number * $Interval; # Re-multiply
330             }
331              
332             1;
333              
334             __END__