File Coverage

blib/lib/Tickit/Widget/SparkLine.pm
Criterion Covered Total %
statement 9 9 100.0
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 12 12 100.0


line stmt bran cond sub pod time code
1             package Tickit::Widget::SparkLine;
2             # ABSTRACT: Simple 'sparkline' widget implementation
3 1     1   24294 use strict;
  1         3  
  1         40  
4 1     1   5 use warnings;
  1         2  
  1         31  
5 1     1   1167 use parent qw(Tickit::Widget);
  1         345  
  1         5  
6              
7             our $VERSION = '0.104';
8              
9             =head1 NAME
10              
11             Tickit::Widget::SparkLine - minimal graph implementation for L
12              
13             =head1 VERSION
14              
15             Version 0.104
16              
17             =head1 SYNOPSIS
18              
19             my $vbox = Tickit::Widget::VBox->new;
20             my $widget = Tickit::Widget::SparkLine->new(
21             data => [ 0, 3, 2, 5, 1, 6, 0, 7 ]
22             );
23             $vbox->add($widget, expand => 1);
24              
25             =head1 DESCRIPTION
26              
27             Generates a mini ("sparkline") graph.
28              
29             =begin HTML
30              
31            

Sparkline widget in action

32              
33             =end HTML
34              
35             =head1 STYLE
36              
37             Set the base style background/foreground to determine the graph colours.
38             Note that reverse video and bold don't work very well on some terminals,
39             since the background+foreground colours won't match.
40              
41             =cut
42              
43             use POSIX qw(floor);
44             use Scalar::Util qw(reftype);
45             use List::Util qw(max sum min);
46             use Tickit::Utils qw(textwidth);
47             use Tickit::Style;
48              
49             BEGIN {
50             style_definition base =>
51             fg => 'white';
52             }
53              
54             =head1 METHODS
55              
56             =cut
57              
58             sub lines { 1 }
59              
60             sub cols {
61             my $self = shift;
62             scalar @{$self->{data}}
63             }
64              
65             =head2 new
66              
67             Instantiate the widget. Takes the following named parameters:
68              
69             =over 4
70              
71             =item * data - graph data
72              
73             =back
74              
75             =cut
76              
77             sub new {
78             my $class = shift;
79             my %args = @_;
80             my $data = delete $args{data};
81             my $self = $class->SUPER::new(%args);
82             $self->{data} = $data || [];
83             $self->resized if $data;
84             return $self;
85             }
86              
87             =head2 data
88              
89             Accessor for stored data.
90              
91             With no parameters, returns the stored data as a list.
92              
93             Pass either an array or an arrayref to set the data values and request display refresh.
94              
95             =cut
96              
97             sub data {
98             my $self = shift;
99             if(@_) {
100             $self->{data} = [ (ref($_[0]) && reftype($_[0]) eq 'ARRAY') ? @{$_[0]} : @_ ];
101             delete $self->{max_value};
102             $self->resized;
103             }
104             return @{ $self->{data} };
105             }
106              
107             =head2 data_chars
108              
109             Returns the set of characters corresponding to the current data values. Each value
110             is assigned a single character, so the string length is equal to the number of data
111             items and represents the minimal string capable of representing all current data
112             items.
113              
114             =cut
115              
116             sub data_chars {
117             my $self = shift;
118             return join '', map { $self->char_for_value($_) } $self->data;
119             }
120              
121             =head2 push
122              
123             Helper method to add one or more items to the end of the list.
124              
125             $widget->push(3,4,2);
126              
127             =cut
128              
129             sub push : method {
130             my $self = shift;
131             push @{$self->{data}}, @_;
132             delete $self->{max_value};
133             $self->resized;
134             }
135              
136             =head2 pop
137              
138             Helper method to remove one item from the end of the list, returns the item.
139              
140             my $item = $widget->pop;
141              
142             =cut
143              
144             sub pop : method {
145             my $self = shift;
146             my $item = pop @{$self->{data}};
147             delete $self->{max_value};
148             $self->resized;
149             return $item;
150             }
151              
152             =head2 shift
153              
154             Helper method to remove one item from the start of the list, returns the item.
155              
156             my $item = $widget->shift;
157              
158             =cut
159              
160             sub shift : method {
161             my $self = shift;
162             my $item = shift @{$self->{data}};
163             delete $self->{max_value};
164             $self->resized;
165             return $item;
166             }
167              
168             =head2 unshift
169              
170             Helper method to add items to the start of the list. Takes a list.
171              
172             $widget->unshift(0, 1, 3);
173              
174             =cut
175              
176             sub unshift : method {
177             my $self = shift;
178             unshift @{$self->{data}}, @_;
179             delete $self->{max_value};
180             $self->resized;
181             }
182              
183             =head2 splice
184              
185             Equivalent to the standard Perl L function.
186              
187             # Insert 3,4,5 at position 2
188             $widget->splice(2, 0, 3, 4, 5);
189              
190             =cut
191              
192             sub splice : method {
193             my $self = shift;
194             my ($offset, $length, @values) = @_;
195              
196             # Specify parameters directly since splice applies a @$$@-ish prototype here
197             my @items = splice @{$self->{data}}, $offset, $length, @values;
198             delete $self->{max_value};
199             $self->resized;
200             return @items;
201             }
202              
203             =head2 graph_steps
204              
205             Returns an arrayref of characters in order of magnitude.
206              
207             For example:
208              
209             [ ' ', qw(_ x X) ]
210              
211             would yield a granularity of 4 steps.
212              
213             Override this in subclasses to provide different visualisations - there's no limit to the number of
214             characters you provide in this arrayref.
215              
216             =cut
217              
218             sub graph_steps { [
219             ord " ",
220             0x2581,
221             0x2582,
222             0x2583,
223             0x2584,
224             0x2585,
225             0x2586,
226             0x2587,
227             0x2588
228             ] }
229              
230             =head2 resample
231              
232             Given a width $w, resamples the data (remaining list of
233             parameters) to fit, using the current L.
234              
235             Used internally.
236              
237             =cut
238              
239             sub resample {
240             my $self = shift;
241             my ($total_width, @data) = @_;
242             my $xdelta = $total_width / @data;
243             my $x = 0;
244             my @v;
245             my @out;
246             my $mode = {
247             average => sub { sum(@_) / @_ },
248             mean => sub { sum(@_) / @_ },
249             median => sub {
250             my @sorted = sort { $a <=> $b } @_;
251             (@sorted % 2) ? $sorted[@_ / 2] : (sum(@sorted[@_ / 2, 1 + @_ / 2]) / 2) },
252             peak => sub { max @_ },
253             min => sub { min @_ },
254             max => sub { max @_ },
255             }->{$self->resample_mode} or die 'bad resample mode: ' . $self->resample_mode;
256              
257             for (@data) {
258             my $last_x = $x;
259             $x += $xdelta;
260             push @v, $_;
261             if(floor($x) - floor($last_x)) {
262             push @out, $mode->(@v);
263             @v = ();
264             }
265             }
266             @out;
267             }
268              
269             =head2 render_to_rb
270              
271             Rendering implementation. Uses L as the base character set.
272              
273             =cut
274              
275             sub render_to_rb {
276             my ($self, $rb) = @_;
277             my $win = $self->window or return;
278             $rb->clear;
279              
280             my @data = @{$self->{data}};
281             my $total_width = $win->cols;
282             my $w = $total_width / (@data || 1);
283             my $floored_w = floor $w;
284              
285             # Apply minimum per-cell width of 1 char, and resample data to fit
286             unless($floored_w) {
287             $w = 1;
288             $floored_w = 1;
289             @data = $self->resample($total_width => @data);
290             }
291              
292             my $win_height = $win->lines;
293             my $x = 0;
294             my $range = $#{$self->graph_steps};
295             my $fg_pen = $self->get_style_pen;
296             my $bg_pen = Tickit::Pen->new(
297             bg => $fg_pen->getattr('fg'),
298             map {; $_ => $fg_pen->getattr($_) } qw(rv b)
299             );
300             foreach my $item (@data) {
301             my $v = $item * $win_height / $self->max_value;
302             my $top = $win_height - floor( $v);
303             my $left = floor(0.5 + $x);
304             my $bar_width = (floor(0.5 + $x + $w) - $left);
305             for my $y ($top .. $win_height) {
306             $rb->erase_at($y, $left, $bar_width, $bg_pen);
307             }
308             my $ch = $self->graph_steps->[floor(0.5 + $range * ($v - floor($v)))];
309             $rb->char_at($top - 1, $left + $_, $ch, $fg_pen) for 0..$bar_width-1;
310             $x += $w;
311             }
312             }
313              
314             =head2 char_for_value
315              
316             Returns the character code corresponding to the given data value.
317              
318             =cut
319              
320             sub char_for_value {
321             my $self = shift;
322             my $item = shift;
323             my $range = $#{$self->graph_steps};
324             return $self->graph_steps->[$item * $range / $self->max_value];
325             }
326              
327             =head2 max_value
328              
329             Returns the maximum value seen so far, used for autoscaling.
330              
331             =cut
332              
333             sub max_value {
334             my $self = shift;
335             return $self->{max_value} if exists $self->{max_value};
336             return $self->{max_value} = max($self->data);
337             }
338              
339             =head2 resample_mode
340              
341             Change method for resampling when we have more data than will fit on the graph.
342              
343             Current values include:
344              
345             =over 4
346              
347             =item * average - takes the average of combined values for this bucket
348              
349             =item * min - lowest value for this bucket
350              
351             =item * median - highest value for this bucket
352              
353             =item * max - largest value for this bucket
354              
355             =item * peak - alias for 'max'
356              
357             =back
358              
359             The default is 'average'.
360              
361             Returns $self if setting a value, or the current value.
362              
363             =cut
364              
365             sub resample_mode {
366             my $self = shift;
367             if(@_) {
368             $self->{resample_mode} = shift;
369             return $self;
370             }
371             return $self->{resample_mode} // 'average';
372             }
373              
374             1;
375              
376             __END__