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