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