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