Tune gnuplot output
[git-chart] / git-chart
1 #!/usr/bin/perl
2
3 # List of general TODO
4 # * default to something else than "the whole history", probably
5 # * if no commits are specified and a commit grouping step is specified,
6 #   limit commits proportionally (e.g. --daily => one week worth of commits)
7
8 use strict;
9 use warnings;
10 use POSIX qw(ceil strftime);
11 use Getopt::Long;
12 use Date::Parse;
13
14 my $SECS_PER_DAY = 24*3600;
15
16 my %steps = (
17         hourly => 3600,
18         daily => $SECS_PER_DAY,
19         weekly => 7*$SECS_PER_DAY,
20         monthly => 30*$SECS_PER_DAY,
21         yearly => 12*30*$SECS_PER_DAY,
22 );
23
24 my %trunc = (
25         hourly => "%Y-%m-%d %H:00",
26         daily => "%Y-%m-%d 00:00",
27         monthly => "%Y-%m-01 00:00",
28         yearly => "%Y-01-01 00:00",
29 );
30
31 sub usage() {
32         # TODO
33         print "Usage: git chart\n";
34 }
35
36 sub gather_data($) {
37         my $options = shift;
38
39         print "Gathering data ...\n";
40
41         my @commits;
42
43         open my $fd, '-|', qw(git log --date=local), '--pretty=%ad,%s', @{$options->{cmdline}};
44         die "failed to get revs" unless $fd;
45         while (<$fd>) {
46                 chomp;
47                 my ($date, $sub) = split ',', $_, 2;
48
49                 push @commits, str2time($date);
50
51         }
52         close $fd;
53
54         @commits = sort @commits;
55
56         print "...done, " . @commits . " commits.\n";
57
58         my $gap = $commits[$#commits] - $commits[0];
59
60         my $step;
61         if (defined $options->{step}) {
62                 $step = $options->{step};
63         } else {
64                 $step = $gap > $steps{yearly} ? 'monthly' :
65                         $gap > $steps{monthly} ? 'weekly' :
66                         $gap > $steps{weekly}/2 ? 'daily' :
67                         'hourly'
68                 ;
69                 $options->{step} = $step;
70         }
71
72         # truncate each commit (date) to the wanted step
73         # e.g. if the step is 'monthly', then commit YYYY/MM/DD
74         # maps to YYYY/MM
75         my %dataset;
76         for my $commit (@commits) {
77                 my $key;
78                 if (exists $trunc{$step}) {
79                         # LOL -- no, seriously, is there a smarter way to do this?
80                         $key = str2time(strftime($trunc{$step}, localtime($commit)));
81                 } else {
82                         $key = $commit - ($commit % $step);
83                 }
84                 if (exists $dataset{$key}) {
85                         $dataset{$key} += 1;
86                 } else {
87                         $dataset{$key} = 1;
88                 }
89         }
90
91         # fill missing steps and find max
92         my @keys = sort keys %dataset;
93
94         # ensure numeric step, taking into account
95         # that our numerical steps are approximate
96         my $tolerance = 0;
97         if (exists $steps{$step}) {
98                 $step = $steps{$step};
99                 $tolerance = $step/2;
100         }
101
102         my $max = 0;
103         my $key = shift @keys;
104         while (1) {
105                 $max = $dataset{$key} if $max < $dataset{$key};
106                 my $next_step = $key + $step;
107                 my $next = shift @keys;
108                 last unless $next;
109                 while (abs($next - $next_step) > $tolerance) {
110                         # next step is too far away
111                         $dataset{$next_step} = 0;
112                         $next_step += $step;
113                 }
114                 $key = $next;
115         }
116
117         $options->{max} = $max;
118         return \%dataset;
119 }
120
121 # functions to plot the datasets.
122 # each function can be called with either one or two parameters.
123 # when called with two parameters, the first is assumed to be the dataset, and the second the options
124 # (array and hash ref respectively).
125 # when called with a single parameter, it is assumed to be an options hash ref, and the dataset is 
126 # created by calling gather_data with the passed options.
127
128 # google chart API
129 # TODO needs a lot of customization
130 sub google_chart($;$) {
131         my $dataset = shift;
132         my $options = shift;
133         if (! defined $options) {
134                 $options = $dataset;
135                 $dataset = gather_data($options);
136         }
137
138         my $height=$options->{chart_height};
139         my $max = $options->{max};
140
141         my @keys = sort keys %$dataset;
142         my $from = $keys[0];
143         my $to = $keys[@keys-1];
144
145         my @data;
146         while (my $key = shift @keys) {
147                 push @data, $dataset->{$key};
148         }
149
150         my $width=ceil(4*$height/3);
151
152         my $url="https://chart.googleapis.com/chart?chs=${width}x${height}&cht=bvg&chd=t:%s&chds=0,$max&chbh=a&chxt=y&chxr=0,0,$max";
153
154         my $launch = sprintf $url, join(",",@data);
155         print $launch, "\n";
156         # `git web--browse "$launch"`
157 }
158
159 # gnuplot
160 sub gnuplot_chart($;$) {
161         my $dataset = shift;
162         my $options = shift;
163         if (! defined $options) {
164                 $options = $dataset;
165                 $dataset = gather_data($options);
166         }
167
168         my @keys = sort keys %$dataset;
169         my $step=$options->{step};
170         my $from = $keys[0];
171         my $to = $keys[$#keys];
172
173         my $data = '';
174         while (my $key = shift @keys) {
175                 $data .= "$key $dataset->{$key}\n";
176         }
177         my $max = $options->{max};
178
179         # add a fake datapoint lest to prevent the last
180         # datum from becoming invibile with style data steps
181         if (exists $steps{$step}) {
182                 $to += $steps{$step};
183         } else {
184                 $to += $step;
185         }
186         $data .="$to 0\n";
187
188         # TODO allow customization
189         # in particular, detect (lack of) display and set term to dumb accordingly
190         my $termcmd = $options->{gnuplot_term};
191
192         my $plotsetup = $options->{gnuplot_setup};
193         $plotsetup .= "\nset yrange [0:$max]\n";
194         $plotsetup .= "set xrange ['$from':'$to']\n";
195         my ($formatx, $ticks);
196         if ($to - $from > $steps{yearly}) {
197                 $formatx = "%Y";
198                 $ticks = $steps{yearly};
199         } elsif ($to - $from > $steps{monthly}) {
200                 $formatx = "%b\\n%Y";
201                 $ticks = $steps{monthly};
202         } elsif ($to - $from > 2*$steps{daily}) {
203                 $formatx = "%d\\n%b";
204                 $ticks = $steps{daily};
205         } else {
206                 $formatx = "%H\\n%d";
207                 $ticks = $steps{hourly};
208         }
209         $plotsetup .= "set format x \"$formatx\"\n";
210         $plotsetup .= "set xtics $ticks\n";
211         my $plotstyle = $options->{gnuplot_style};
212         my $plotoptions = $options->{gnuplot_plotwith};
213
214         open my $gp, "|gnuplot -persist";
215
216         my $gp_script = <<GPCMD
217 $termcmd
218 set xdata time
219 set timefmt "%s"
220 $plotsetup
221 $plotstyle
222 plot "-" using 1:2 $plotoptions
223 $data
224 GPCMD
225         ;
226
227         print STDOUT $gp_script;
228         print $gp $gp_script;
229         close $gp;
230 }
231
232 # some defaults
233 my %options = (
234         cmdline => [],
235         # charting/plotting options
236         plotter => \&gnuplot_chart,
237         chart_height => 144,
238         gnuplot_term => '',
239         gnuplot_setup => "set nokey",
240         gnuplot_style => 'set style data steps',
241         gnuplot_plotwith => '',
242 );
243
244 sub parse_step(@) {
245         my $key = shift;
246         my $step = shift;
247         if (exists $steps{$key}) {
248                 $options{step} = $key;
249                 return;
250         }
251         die "this can't happen ($key)" unless $key eq 'step';
252
253         if ($step =~/^\d+$/) {
254                 $options{step} = 0 + $step;
255                 return
256         } else {
257                 if (exists $steps{$step}) {
258                         $options{step} = $step;
259                         return;
260                 }
261                 die "unknown step $step";
262         }
263 }
264
265 # read our options first
266 Getopt::Long::Configure('pass_through');
267 GetOptions(
268         'hourly' => \&parse_step,
269         'daily' => \&parse_step,
270         'weekly' => \&parse_step,
271         'monthly' => \&parse_step,
272         'yearly' => \&parse_step,
273         'step=s' => sub { parse_step(@_) },
274         'chart-height=i' => sub { $options{chart_height} = $_[1]},
275         google => sub { $options{plotter} = \&google_chart },
276         gnuplot => sub { $options{plotter} = \&gnuplot_chart },
277 );
278
279 # if anything was left, check for log options
280 if (@ARGV) {
281         $options{cmdline} = \@ARGV;
282 }
283
284 die "step must be strictly positive!" if defined $options{step} and not $options{step} > 0;
285
286 $options{plotter}->(\%options);