ab5c023398e94300363dfd1a23d991782b8d91e0
[barcat.git] / barcat
1 #!/usr/bin/perl -CA
2 use 5.014;
3 use warnings;
4 use utf8;
5 use List::Util qw( min max sum );
6 use open qw( :std :utf8 );
7 use re '/msx';
8
9 our $VERSION = '1.07';
10
11 use Getopt::Long '2.33', qw( :config gnu_getopt );
12 my %opt;
13 GetOptions(\%opt,
14         'ascii|a!',
15         'color|c!',
16         'C' => sub { $opt{color} = 0 },
17         'field|f=s' => sub {
18                 eval {
19                         local $_ = $_[1];
20                         $opt{anchor} = /\A[0-9]+\z/ ? qr/(?:\S*\h+){$_}\K/ : qr/$_/;
21                 } or die $@ =~ s/(?:\ at\ \N+)?\Z/ for option $_[0]/r;
22         },
23         'human-readable|H!',
24         'interval|t:i',
25         'trim|length|l=s' => sub {
26                 my ($optname, $optval) = @_;
27                 $optval =~ s/%$// and $opt{trimpct}++;
28                 $optval =~ m/\A-?[0-9]+\z/ or die(
29                         "Value \"$optval\" invalid for option $optname",
30                         " (number or percentage expected)\n"
31                 );
32                 $opt{trim} = $optval;
33         },
34         'value-length=i',
35         'hidemin=i',
36         'hidemax=i',
37         'minval=f',
38         'maxval=f',
39         'limit|L:s' => sub {
40                 my ($optname, $optval) = @_;
41                 $optval ||= 0;
42                 $optval =~ /\A-[0-9]+\z/ and $optval .= '-';  # tail shorthand
43                 ($opt{hidemin}, $opt{hidemax}) =
44                 $optval =~ m/\A (?: (-? [0-9]+)? - )? ([0-9]+)? \z/ or die(
45                         "Value \"$optval\" invalid for option limit",
46                         " (range expected)\n"
47                 );
48         },
49         'header!',
50         'markers|m=s',
51         'graph-format=s' => sub {
52                 $opt{'graph-format'} = substr $_[1], 0, 1;
53         },
54         'spark:s' => sub {
55                 $opt{spark} = [split //,
56                         $_[1] || ($opt{ascii} ? ' ..oOO' : ' ▁▂▃▄▅▆▇█')
57                 ];
58         },
59         'palette=s' => sub {
60                 $opt{palette} = {
61                         fire   => [qw( 90 31 91 33 93 97 96 )],
62                         fire88 => [map {"38;5;$_"} qw(
63                                 80  32 48 64  68 72 76  77 78 79  47
64                         )],
65                         fire256=> [map {"38;5;$_"} qw(
66                                 235  52 88 124 160 196
67                                 202 208 214 220 226  227 228 229 230 231  159
68                         )],
69                         ramp88 => [map {"38;5;$_"} qw(
70                                 64 65 66 67 51 35 39 23 22 26 25 28
71                         )],
72                         whites => [qw( 1;30 0;37 1;37 )],
73                         greys  => [map {"38;5;$_"} 52, 235..255, 47],
74                 }->{$_[1]} // [ split /[^0-9;]/, $_[1] ];
75         },
76         'stat|s!',
77         'signal-stat=s',
78         'unmodified|u!',
79         'width|w=i',
80         'version' => sub {
81                 say "barcat version $VERSION";
82                 exit;
83         },
84         'usage|h' => sub {
85                 /^=/ ? last : print for readline *DATA;  # text between __END__ and pod
86                 exit;
87         },
88         'help|?'  => sub {
89                 require Pod::Usage;
90                 Pod::Usage::pod2usage(
91                         -exitval => 0, -perldocopt => '-oman', -verbose => 2,
92                 );
93         },
94 ) or exit 64;  # EX_USAGE
95
96 $opt{width} ||= $ENV{COLUMNS} || qx(tput cols) || 80 unless $opt{spark};
97 $opt{color} //= -t *STDOUT;  # enable on tty
98 $opt{'graph-format'} //= '-';
99 $opt{trim}   *= $opt{width} / 100 if $opt{trimpct};
100 $opt{units}   = [split //, ' kMGTPEZYyzafpn'.($opt{ascii} ? 'u' : 'μ').'m']
101         if $opt{'human-readable'};
102 $opt{anchor} //= qr/\A/;
103 $opt{'value-length'} = 6 if $opt{units};
104 $opt{'value-length'} = 1 if $opt{unmodified};
105 $opt{'signal-stat'} //= exists $SIG{INFO} ? 'INFO' : 'QUIT';
106 $opt{markers} //= '=avg >31.73v <68.27v +50v |0';
107 $opt{palette} //= $opt{color} && [31, 90, 32];
108 $opt{hidemin} = ($opt{hidemin} || 1) - 1;
109 $opt{input} = (@ARGV && $ARGV[0] =~ m/\A[-0-9]/) ? \@ARGV : undef
110         and undef $opt{interval};
111
112 $opt{'sum-format'} = sub { sprintf '%.8g', $_[0] };
113 $opt{'calc-format'} = sub { sprintf '%*.*f', 0, 2, $_[0] };
114 $opt{'value-format'} = $opt{units} && sub {
115         my $unit = (
116                 log(abs $_[0] || 1) / log(10)
117                 - 3 * (abs($_[0]) < .9995)   # shift to smaller unit if below 1
118                 + 1e-15  # float imprecision
119         );
120         my $decimal = ($unit % 3) == ($unit < 0);
121         $unit -= log($decimal ? .995 : .9995) / log(10);  # rounded
122         $decimal = ($unit % 3) == ($unit < 0);
123         $decimal &&= $_[0] !~ /^-?0*[0-9]{1,3}$/;  # integer 0..999
124         sprintf('%*.*f%1s',
125                 3 + ($_[0] < 0), # digits plus optional negative sign
126                 $decimal,  # tenths
127                 $_[0] / 1000 ** int($unit/3),  # number
128                 $#{$opt{units}} * 1.5 < abs $unit ? sprintf('e%d', $unit) :
129                         $opt{units}->[$unit/3]  # suffix
130         );
131 };
132
133
134 my (@lines, @values, @order);
135
136 $SIG{$_} = \&show_stat for $opt{'signal-stat'} || ();
137 $SIG{ALRM} = sub {
138         show_lines();
139         alarm $opt{interval} if defined $opt{interval} and $opt{interval} > 0;
140 };
141 $SIG{INT} = \&show_exit;
142
143 if (defined $opt{interval}) {
144         $opt{interval} ||= 1;
145         alarm $opt{interval} if $opt{interval} > 0;
146
147         eval {
148                 require Tie::Array::Sorted;
149                 tie @order, 'Tie::Array::Sorted', sub { $_[1] <=> $_[0] };
150         } or warn $@, "Expect slowdown with large datasets!\n";
151 }
152
153 my $valmatch = qr<
154         $opt{anchor} ( \h* -? [0-9]* [.]? [0-9]+ (?: e[+-]?[0-9]+ )? |)
155 >x;
156 while (defined ($_ = $opt{input} ? shift @{ $opt{input} } : readline)) {
157         s/\r?\n\z//;
158         s/\A\h*// unless $opt{unmodified};
159         my $valnum = s/$valmatch/\n/ && $1;
160         push @values, $valnum;
161         push @order, $valnum if length $valnum;
162         if (defined $opt{trim} and defined $valnum) {
163                 my $trimpos = abs $opt{trim};
164                 $trimpos -= length $valnum if $opt{unmodified};
165                 if ($trimpos <= 1) {
166                         $_ = substr $_, 0, 2;
167                 }
168                 elsif (length > $trimpos) {
169                         # cut and replace (intentional lvalue for speed, contrary to PBP)
170                         substr($_, $trimpos - 1) = $opt{ascii} ? '>' : '…';
171                 }
172         }
173         push @lines, $_;
174         show_lines() if defined $opt{interval} and $opt{interval} < 0
175                 and $. % $opt{interval} == 0;
176 }
177
178 if ($opt{'zero-missing'}) {
179         push @values, (0) x 10;
180 }
181
182 $SIG{INT} = 'DEFAULT';
183
184 sub color {
185         $opt{color} and defined $_[0] or return '';
186         return "\e[$_[0]m" if defined wantarray;
187         $_ = color(@_) . $_ . color(0) if defined;
188 }
189
190 sub show_lines {
191
192 state $nr =
193         $opt{hidemin} < 0 ? @lines + $opt{hidemin} + 1 :
194         $opt{hidemin};
195 @lines > $nr or return;
196
197 my $limit = $#lines;
198 if (defined $opt{hidemax}) {
199         if ($opt{hidemin} and $opt{hidemin} < 0) {
200                 $limit -= $opt{hidemax} - 1;
201         }
202         else {
203                 $limit = $opt{hidemax} - 1;
204         }
205 }
206
207 @order = sort { $b <=> $a } @order unless tied @order;
208 my $maxval = $opt{maxval} // (
209         $opt{hidemax} ? max grep { length } @values[$nr .. $limit] :
210         $order[0]
211 ) // 0;
212 my $minval = $opt{minval} // min $order[-1] // (), 0;
213 my $range = $maxval - $minval;
214 my $lenval = $opt{'value-length'} // max map { length } @order;
215 my $len    = defined $opt{trim} && $opt{trim} <= 0 ? -$opt{trim} + 1 :
216         max map { length $values[$_] && length $lines[$_] }
217                 0 .. min $#lines, $opt{hidemax} || ();  # left padding
218 my $size   = defined $opt{width} && $range &&
219         ($opt{width} - $lenval - $len) / $range;  # bar multiplication
220
221 my @barmark;
222 if ($opt{markers} and $size > 0) {
223         for my $markspec (split /\h/, $opt{markers}) {
224                 my ($char, $func) = split //, $markspec, 2;
225                 my $pos = eval {
226                         if ($func eq 'avg') {
227                                 return sum(@order) / @order;
228                         }
229                         elsif ($func =~ /\A([0-9.]+)v\z/) {
230                                 die "Invalid marker $char: percentile $1 out of bounds\n" if $1 > 100;
231                                 my $index = $#order * $1 / 100;
232                                 return ($order[$index] + $order[$index + .5]) / 2;
233                         }
234                         elsif ($func =~ /\A-?[0-9.]+\z/) {
235                                 return $func;
236                         }
237                         else {
238                                 die "Unknown marker $char: $func\n";
239                         }
240                 };
241                 defined $pos or do {
242                         warn $@ if $@;
243                         next;
244                 };
245                 $pos -= $minval;
246                 $pos >= 0 or next;
247                 color(36) for $barmark[$pos * $size] = $char;
248         }
249
250         state $lastmax = $maxval;
251         if ($maxval > $lastmax) {
252                 print ' ' x ($lenval + $len);
253                 printf color(90);
254                 printf '%-*s',
255                         ($lastmax - $minval) * $size + .5,
256                         '-' x (($values[$nr - 1] - $minval) * $size);
257                 print color(92);
258                 say '+' x (($range - $lastmax) * $size + .5);
259                 print color(0);
260                 $lastmax = $maxval;
261         }
262 }
263
264 say(
265         color(31), sprintf('%*s', $lenval, $minval),
266         color(90), '-', color(36), '+',
267         color(32), sprintf('%*s', $size * $range - 3, $maxval),
268         color(90), '-', color(36), '+',
269         color(0),
270 ) if $opt{header};
271
272 while ($nr <= $limit) {
273         my $val = $values[$nr];
274         my $rel = length $val && $range && ($val - $minval) / $range;
275         my $color = !length $val || !$opt{palette} ? undef :
276                 $val == $order[0] ? $opt{palette}->[-1] : # max
277                 $val == $order[-1] ? $opt{palette}->[0] : # min
278                 $opt{palette}->[ $rel * ($#{$opt{palette}} - 1) + 1 ];
279
280         if ($opt{spark}) {
281                 say '' if $opt{width} and $nr and $nr % $opt{width} == 0;
282                 print color($color), $opt{spark}->[
283                         !$val || !$#{$opt{spark}} ? 0 : # blank
284                         $val == $order[0] ? -1 : # max
285                         $val == $order[-1] ? 1 : # min
286                         $#{$opt{spark}} < 3 ? 1 :
287                         $rel * ($#{$opt{spark}} - 3) + 2.5
288                 ];
289                 next;
290         }
291
292         if (length $val) {
293                 $val = $opt{'value-format'} ? $opt{'value-format'}->($val) :
294                         sprintf "%*s", $lenval, $val;
295                 color($color) for $val;
296         }
297         my $line = $lines[$nr] =~ s/\n/$val/r;
298         if (not length $val) {
299                 say $line;
300                 next;
301         }
302         printf '%-*s', $len + length($val), $line;
303         print $barmark[$_] // $opt{'graph-format'}
304                 for 1 .. $size && (($values[$nr] || 0) - $minval) * $size + .5;
305         say '';
306 }
307 continue {
308         $nr++;
309 }
310 say $opt{palette} ? color(0) : '' if $opt{spark};
311
312         return $nr;
313 }
314
315 sub show_stat {
316         if ($opt{hidemin} or $opt{hidemax}) {
317                 my $linemin = $opt{hidemin};
318                 my $linemax = ($opt{hidemax} || @lines) - 1;
319                 if ($linemin < 0) {
320                         $linemin += @lines;
321                         $linemax = @lines - $linemax;
322                 }
323                 printf '%.8g of ', $opt{'sum-format'}->(
324                         sum(grep {length} @values[$linemin .. $linemax]) // 0
325                 );
326         }
327         if (@order) {
328                 my $total = sum @order;
329                 printf '%s total', color(1) . $opt{'sum-format'}->($total) . color(0);
330                 printf ' in %d values', scalar @order;
331                 printf ' over %d lines', scalar @lines if @order != @lines;
332                 printf(' (%s min, %s avg, %s max)',
333                         color(31) . ($opt{'value-format'} || sub {$_[0]})->($order[-1]) . color(0),
334                         color(36) . ($opt{'value-format'} || $opt{'calc-format'})->($total / @order) . color(0),
335                         color(32) . ($opt{'value-format'} || sub {$_[0]})->($order[0]) . color(0),
336                 );
337         }
338         say '';
339         return 1;
340 }
341
342 sub show_exit {
343         show_lines();
344         show_stat() if $opt{stat};
345         exit 130 if @_;  # 0x80+signo
346         exit;
347 }
348
349 show_exit();
350
351 __END__
352 Usage:
353   barcat [OPTIONS] [FILES|NUMBERS]
354
355 Options:
356   -a, --[no-]ascii         Restrict user interface to ASCII characters
357   -c, --[no-]color         Force colored output of values and bar markers
358   -f, --field=(N|REGEXP)   Compare values after a given number of whitespace
359                            separators
360       --header             Prepend a chart axis with minimum and maximum
361                            values labeled
362   -H, --human-readable     Format values using SI unit prefixes
363   -t, --interval[=(N|-LINES)]
364                            Output partial progress every given number of
365                            seconds or input lines
366   -l, --length=[-]SIZE[%]  Trim line contents (between number and bars)
367   -L, --limit[=(N|-LAST|START-[END])]
368                            Stop output after a number of lines
369       --graph-format=CHAR  Glyph to repeat for the graph line
370   -m, --markers=FORMAT     Statistical positions to indicate on bars
371       --min=N, --max=N     Bars extend from 0 or the minimum value if lower
372       --palette=(PRESET|COLORS)
373                            Override colors of parsed numbers
374       --spark[=CHARS]      Replace lines by sparklines
375   -s, --stat               Total statistics after all data
376   -u, --unmodified         Do not reformat values, keeping leading whitespace
377       --value-length=SIZE  Reserved space for numbers
378   -w, --width=COLUMNS      Override the maximum number of columns to use
379   -h, --usage              Overview of available options
380       --help               Full documentation
381       --version            Version information
382
383 =encoding utf8
384
385 =head1 NAME
386
387 barcat - graph to visualize input values
388
389 =head1 SYNOPSIS
390
391 B<barcat> [<options>] [<file>... | <numbers>]
392
393 =head1 DESCRIPTION
394
395 Visualizes relative sizes of values read from input
396 (parameters, file(s) or STDIN).
397 Contents are concatenated similar to I<cat>,
398 but numbers are reformatted and a bar graph is appended to each line.
399
400 Don't worry, barcat does not drink and divide.
401 It can has various options for input and output (re)formatting,
402 but remains limited to one-dimensional charts.
403 For more complex graphing needs
404 you'll need a larger animal like I<gnuplot>.
405
406 =head1 OPTIONS
407
408 =over
409
410 =item -a, --[no-]ascii
411
412 Restrict user interface to ASCII characters,
413 replacing default UTF-8 by their closest approximation.
414 Input is always interpreted as UTF-8 and shown as is.
415
416 =item -c, --[no-]color
417
418 Force colored output of values and bar markers.
419 Defaults on if output is a tty,
420 disabled otherwise such as when piped or redirected.
421
422 =item -f, --field=(<number> | <regexp>)
423
424 Compare values after a given number of whitespace separators,
425 or matching a regular expression.
426
427 Unspecified or I<-f0> means values are at the start of each line.
428 With I<-f1> the second word is taken instead.
429 A string can indicate the starting position of a value
430 (such as I<-f:> if preceded by colons),
431 or capture the numbers itself,
432 for example I<-f'(\d+)'> for the first digits anywhere.
433
434 =item --header
435
436 Prepend a chart axis with minimum and maximum values labeled.
437
438 =item -H, --human-readable
439
440 Format values using SI unit prefixes,
441 turning long numbers like I<12356789> into I<12.4M>.
442 Also changes an exponent I<1.602176634e-19> to I<160.2z>.
443 Short integers are aligned but kept without decimal point.
444
445 =item -t, --interval[=(<seconds> | -<lines>)]
446
447 Output partial progress every given number of seconds or input lines.
448 An update can also be forced by sending a I<SIGALRM> alarm signal.
449
450 =item -l, --length=[-]<size>[%]
451
452 Trim line contents (between number and bars)
453 to a maximum number of characters.
454 The exceeding part is replaced by an abbreviation sign,
455 unless C<--length=0>.
456
457 Prepend a dash (i.e. make negative) to enforce padding
458 regardless of encountered contents.
459
460 =item -L, --limit[=(<count> | -<last> | <start>-[<end>])]
461
462 Stop output after a number of lines.
463 A single value indicates the last line number (like C<head>),
464 or first line counting from the bottom if negative (like C<tail>).
465 A specific range can be given by two values.
466
467 All input is still counted and analyzed for statistics,
468 but disregarded for padding and bar size.
469
470 =item --graph-format=<character>
471
472 Glyph to repeat for the graph line.
473 Defaults to a dash C<->.
474
475 =item -m, --markers=<format>
476
477 Statistical positions to indicate on bars.
478 A single indicator glyph precedes each position:
479
480 =over 2
481
482 =item <number>
483
484 Exact value to match on the axis.
485 A vertical bar at the zero crossing is displayed by I<|0>
486 for negative values.
487 For example I<:3.14> would show a colon at pi.
488
489 =item <percentage>I<v>
490
491 Ranked value at the given percentile.
492 The default shows I<+> at I<50v> for the mean or median;
493 the middle value or average between middle values.
494 One standard deviation right of the mean is at about I<68.3v>.
495 The default includes I<< >31.73v <68.27v >>
496 to encompass all I<normal> results, or 68% of all entries, by B<< <--> >>.
497
498 =item I<avg>
499
500 Matches the average;
501 the sum of all values divided by the number of counted lines.
502 Indicated by default as I<=>.
503
504 =back
505
506 =item --min=<number>, --max=<number>
507
508 Bars extend from 0 or the minimum value if lower,
509 to the largest value encountered.
510 These options can be set to customize this range.
511
512 =item --palette=(<preset> | <color>...)
513
514 Override colors of parsed numbers.
515 Can be any CSI escape, such as I<90> for default dark grey,
516 or alternatively I<1;30> for bright black.
517
518 In case of additional colors,
519 the last is used for values equal to the maximum, the first for minima.
520 If unspecified, these are green and red respectively (I<31 90 32>).
521 Multiple intermediate colors will be distributed
522 relative to the size of values.
523
524 Predefined color schemes are named I<whites> and I<fire>,
525 or I<greys> and I<fire256> for 256-color variants.
526
527 =item --spark[=<characters>]
528
529 Replace lines by I<sparklines>,
530 single characters corresponding to input values.
531 A specified sequence of unicode characters will be used for
532 Of a specified sequence of unicode characters,
533 the first one will be used for non-values,
534 the last one for the maximum,
535 the second (if any) for the minimum,
536 and any remaining will be distributed over the range of values.
537 Unspecified, block fill glyphs U+2581-2588 will be used.
538
539 =item -s, --stat
540
541 Total statistics after all data.
542
543 =item -u, --unmodified
544
545 Do not reformat values, keeping leading whitespace.
546 Keep original value alignment, which may be significant in some programs.
547
548 =item --value-length=<size>
549
550 Reserved space for numbers.
551
552 =item -w, --width=<columns>
553
554 Override the maximum number of columns to use.
555 Appended graphics will extend to fill up the entire screen.
556
557 =item -h, --usage
558
559 Overview of available options.
560
561 =item --help
562
563 Full documentation
564 rendered by perldoc.
565
566 =item --version
567
568 Version information.
569
570 =back
571
572 =head1 EXAMPLES
573
574 Draw a sine wave:
575
576     seq 30 | awk '{print sin($1/10)}' | barcat
577
578 Compare file sizes (with human-readable numbers):
579
580     du -d0 -b * | barcat -H
581
582 Memory usage of user processes with long names truncated:
583
584     ps xo %mem,pid,cmd | barcat -l40
585
586 Monitor network latency from prefixed results:
587
588     ping google.com | barcat -f'time=\K' -t
589
590 Commonly used after counting, for example users on the current server:
591
592     users | tr ' ' '\n' | sort | uniq -c | barcat
593
594 Letter frequencies in text files:
595
596     cat /usr/share/games/fortunes/*.u8 |
597     perl -CS -nE 'say for grep length, split /\PL*/, uc' |
598     sort | uniq -c | barcat
599
600 Number of HTTP requests per day:
601
602     cat log/access.log | cut -d\  -f4 | cut -d: -f1 | uniq -c | barcat
603
604 Any kind of database query with counts, preserving returned alignment:
605
606     echo 'SELECT count(*),schemaname FROM pg_tables GROUP BY 2' |
607     psql -t | barcat -u
608
609 In PostgreSQL from within the client:
610
611         postgres=> SELECT sin(generate_series(0, 3, .1)) \g |barcat
612
613 Earthquakes worldwide magnitude 1+ in the last 24 hours:
614
615     curl https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/1.0_day.csv |
616     column -tns, | barcat -f4 -u -l80%
617
618 External datasets, like movies per year:
619
620     curl https://github.com/prust/wikipedia-movie-data/raw/master/movies.json -L |
621     perl -054 -nlE 'say if s/^"year"://' | uniq -c | barcat
622
623 But please get I<jq> to process JSON
624 and replace the manual selection by C<< jq '.[].year' >>.
625
626 Pokémon height comparison:
627
628     curl https://github.com/Biuni/PokemonGO-Pokedex/raw/master/pokedex.json -L |
629     jq -r '.pokemon[] | [.height,.num,.name] | join(" ")' | barcat
630
631 USD/EUR exchange rate from CSV provided by the ECB:
632
633     curl https://sdw.ecb.europa.eu/export.do \
634          -Gd 'node=SEARCHRESULTS&q=EXR.D.USD.EUR.SP00.A&exportType=csv' |
635     grep '^[12]' | barcat -f',\K' --value-length=7
636
637 Total population history in XML from the World Bank:
638
639     curl http://api.worldbank.org/v2/country/1W/indicator/SP.POP.TOTL -L |
640     xmllint --xpath '//*[local-name()="date" or local-name()="value"]' - |
641     sed -r 's,</wb:value>,\n,g; s,(<[^>]+>)+, ,g' | barcat -f1 -H
642
643 And of course various Git statistics, such commit count by year:
644
645     git log --pretty=%ci | cut -b-4 | uniq -c | barcat
646
647 Or the top 3 most frequent authors with statistics over all:
648
649     git shortlog -sn | barcat -L3 -s
650
651 Sparkline graphics of simple input given as inline parameters:
652
653         barcat --spark= 3 1 4 1 5 0 9 2 4
654
655 Activity graph of the last days (substitute date C<-v-{}d> on BSD):
656
657     ( git log --pretty=%ci --since=30day | cut -b-10
658       seq 0 30 | xargs -i date +%F -d-{}day ) |
659     sort | uniq -c | awk '$1--' | barcat --spark
660
661 =head1 AUTHOR
662
663 Mischa POSLAWSKY <perl@shiar.org>
664
665 =head1 LICENSE
666
667 GPL3+.