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