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