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