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