File Coverage

blib/lib/Image/Timeline.pm
Criterion Covered Total %
statement 4 6 66.6
branch n/a
condition n/a
subroutine 2 2 100.0
pod n/a
total 6 8 75.0


line stmt bran cond sub pod time code
1             package Image::Timeline;
2              
3 3     3   1484 use strict;
  3         6  
  3         77  
4 3     3   3877 use GD;
  0            
  0            
5              
6             # Not a required module, but try to load at compile time
7             BEGIN {eval "use Date::Format"}
8              
9             use vars qw($VERSION);
10             $VERSION = 0.11;
11              
12             sub new {
13             my ($pkg, %args) = @_;
14             my $self = {
15             width => 900,
16             font => gdTinyFont,
17             bar_stepsize => 50, # The gridsize for the top reference bar
18             vspacing => 2,
19             hmargin => 3,
20             bg_color => [255,255,255],
21             bar_color => [255,0,0],
22             endcap_color => [0,155,0],
23             legend_color => [0,0,0],
24             text_color => [0,0,0],
25             date_format => '',
26             to_string => sub { $_[0] },
27             right_margin => 0,
28             %args
29             };
30              
31             # subtract right_margin to width to avoid cutting last legend
32             $self->{width} -= $self->{right_margin};
33             return bless $self, $pkg;
34             }
35              
36             sub add {
37             my ($self, $label, $start, $end) = @_;
38            
39             $self->{data}{$label}{start} = $start;
40             $self->{data}{$label}{end} = $end;
41             }
42              
43             sub write {
44             my ($self, $format, $filename, @args) = @_;
45              
46             my $image = $self->draw;
47              
48             local *OUT;
49             open OUT, ">$filename" or die "Can't create '$filename': $!";
50             binmode(OUT);
51             print OUT $image->$format(@args);
52             close OUT;
53             }
54              
55             sub write_png { my $s = shift; $s->write('png', @_) }
56             sub write_gif { my $s = shift; $s->write('gif', @_) }
57              
58             sub _create_image {
59             my ($self, $w, $h) = @_;
60             my $image = GD::Image->new($w + $self->{right_margin},$h);
61              
62             # Allocate some colors
63             foreach (qw(bg bar endcap legend text)) {
64             $self->{colors}{$_} = $image->colorAllocate(@{$self->{"${_}_color"}});
65             }
66             $image->fill(0,0,$self->{colors}{bg});
67              
68             return $image;
69             }
70              
71             sub _create_channels {
72             my ($self) = @_;
73            
74             my $data = $self->{data};
75             my $channels = $self->{channels} = [];
76            
77             # Populate the channels
78             LOOP: foreach my $label (sort {$data->{$a}{'start'} <=> $data->{$b}{'start'}} keys %$data) {
79             #warn "Inserting '$label'";
80             # Check each channel to find an empty space:
81             foreach my $channel (@$channels) {
82             if ($self->_channel_is_free($channel, $data->{$label}{'start'})) {
83             $self->_add_to_channel($channel, $label);
84             #warn "Adding '$label' to existing channel $channel";
85             next LOOP;
86             }
87             }
88            
89             # All channels are full for this start-time. Make a new channel.
90             push @$channels, my $new = {};
91             $self->_add_to_channel($new, $label);
92             #warn "Adding '$label' to new channel";
93             }
94             }
95              
96             sub _minmax {
97             # Find min & max dates
98             my ($self) = @_;
99             return ($self->{min}, $self->{max}) if exists $self->{min};
100              
101             my ($min,$max) = map {$_->{start}, $_->{end}} (each %{$self->{channels}[0]})[1];
102             foreach my $channel (@{$self->{channels}}) {
103             foreach my $entry (values %$channel) {
104             if ($entry->{start} < $min) {$min = $entry->{start}}
105             if ($entry->{end} > $max) {$max = $entry->{end}}
106             }
107             }
108             return ($self->{min}, $self->{max}) = ($min, $max);
109             }
110              
111             sub draw_legend {
112             # Draw the top legend bar
113             my ($self, $image) = @_;
114             my ($min, $max) = $self->_minmax;
115             my $color = $self->{colors}{legend};
116            
117             my $step = $self->{bar_stepsize}; # For convenience
118             if ($step =~ /^(\d+)%$/) { # Convert from percentage
119             $step = ($max - $min) * $1 / 100;
120             }
121              
122             my $start_at = int($min/$step) * $step;
123             for (my $i=$start_at; $i <= $max + $step; $i += $step) {
124             $image->line($self->_convert($i), 2, $self->_convert($i), 8, $color);
125             my $label = $self->{date_format} ? $self->_UTC_to_string($i) : $self->{to_string}->($i);
126             $image->string($self->{font}, $self->_convert($i)+1, 4, $label, $color);
127             }
128            
129             # Long top line
130             $image->line($self->_convert($start_at), 2, $self->_convert((int($max/$step)+1) * $step) + $self->{right_margin}, 2, $color);
131             }
132              
133             sub _convert {
134             # A little baroque ... converts date to x-value
135             my ($self, $time) = @_;
136             return ( $time - $self->{min}) * ($self->{width}-2*$self->{hmargin})
137             / ($self->{max} - $self->{min})
138             + $self->{hmargin};
139             }
140              
141             sub draw_channels {
142             my ($self, $image) = @_;
143             my ($fheight, $fwidth) = ($self->{font}->height,$self->{font}->width);
144              
145             my $y;
146             foreach my $channel ({}, @{$self->{channels}}) { # leave an empty channel at the top
147             $y += $self->{height}/(@{$self->{channels}} + 2);
148            
149             # Need to draw them in order to avoid collisions
150             my @labels = sort {$channel->{$a}{start} <=> $channel->{$b}{start}} keys %$channel;
151             my $above = 0;
152             foreach my $i (0..$#labels) {
153             my $label = $labels[$i];
154             my $x_start = $self->_convert($channel->{$label}{start});
155             my $x_end = $self->_convert($channel->{$label}{end});
156              
157             # Draw the long line:
158             $image->line($x_start, $y, $x_end, $y, $self->{colors}{bar});
159              
160             # Draw the endcaps:
161             $image->line($x_start, $y-1, $x_start, $y+1, $self->{colors}{endcap});
162             $image->line($x_end, $y-1, $x_end, $y+1, $self->{colors}{endcap});
163              
164             # Write the label (above the bar if it would collide)
165             if ($above) { $above = 0 }
166             elsif (!defined $labels[$i+1]) { $above = 0 }
167             elsif (length($label) * $fwidth >
168             $self->_convert($channel->{$labels[$i+1]}{start}) - $x_start) { $above = 1 }
169             else { $above = 0 }
170            
171             my $string_y = ($above ?
172             $y - $fheight - 1 :
173             $y + 1);
174             $image->string($self->{font}, $x_start, $string_y, $label, $self->{colors}{text});
175             }
176             }
177             }
178              
179             sub draw {
180             my ($self) = @_;
181            
182             $self->_create_channels;
183              
184             # Add 2 to leave room for header
185             my $fheight = $self->{font}->height;
186             $self->{height} = (@{$self->{channels}} + 2) * (2*$fheight + $self->{vspacing});
187            
188             my $image = $self->_create_image($self->{width}, $self->{height});
189             $self->draw_legend($image);
190             $self->draw_channels($image);
191            
192             return $image;
193             }
194              
195             sub _channel_is_free {
196             my ($self, $channel, $time) = @_;
197              
198             # Step through the entries in this channel:
199             foreach my $data (values %$channel) {
200             return 0 if ($data->{start} <= $time
201             and
202             $data->{end} >= $time);
203             }
204              
205             return 1;
206             }
207              
208             sub _add_to_channel {
209             my ($self, $channel, $label) = @_;
210            
211             foreach (qw(start end)) {
212             $channel->{$label}{$_} = $self->{data}{$label}{$_};
213             }
214             }
215              
216             sub _UTC_to_string {
217             my ($self,$UTC) = @_;
218            
219             require Date::Format;
220             return Date::Format::time2str($self->{date_format}, $UTC);
221             }
222              
223             1;
224             __END__