File Coverage

blib/lib/Text/Chart.pm
Criterion Covered Total %
statement 23 156 14.7
branch 0 62 0.0
condition 0 16 0.0
subroutine 8 12 66.6
pod 1 1 100.0
total 32 247 12.9


line stmt bran cond sub pod time code
1             package Text::Chart;
2              
3 1     1   289029 use 5.010001;
  1         5  
4 1     1   8 use strict;
  1         2  
  1         33  
5 1     1   884 use utf8;
  1         552  
  1         8  
6 1     1   46 use warnings;
  1         3  
  1         58  
7 1     1   2811 use Log::ger;
  1         66  
  1         6  
8              
9 1     1   301 use Exporter qw(import);
  1         3  
  1         75  
10 1     1   1039 use List::MoreUtils qw(minmax);
  1         47723  
  1         11  
11 1     1   1722 use Scalar::Util qw(looks_like_number);
  1         2  
  1         2837  
12              
13             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
14             our $DATE = '2024-02-06'; # DATE
15             our $DIST = 'Text-Chart'; # DIST
16             our $VERSION = '0.042'; # VERSION
17              
18             our @EXPORT_OK = qw(gen_text_chart);
19              
20             our %SPEC;
21              
22             our @CHART_TYPES = (
23             'raw',
24             'bar',
25             #'column',
26             'sparkline',
27             #hsparkline
28             #line
29             #pie
30             #area (see Google Charts API)
31             #tree map (see Google Charts API)
32             );
33              
34             my @sparkline_chars = split //, '▁▂▃▄▅▆▇█';
35             my @hsparkline_chars = split //, '▏▎▍▌▋▊▉█';
36              
37             sub _get_column_data {
38 0     0     my ($tbl, $col) = @_;
39 0           my $res = $tbl->select_as_aoaos([$col]);
40 0           my $coldata = [];
41 0           for (@{ $res->{data} }) {
  0            
42 0           push @$coldata, $_->[0];
43             }
44 0           $coldata;
45             }
46              
47             sub _find_first_numcol {
48 0     0     my $tbl = shift;
49              
50             COL:
51 0           for my $col (@{ $tbl->cols_by_idx }) {
  0            
52 0           my $coldata = _get_column_data($tbl, $col);
53 0           my $is_numeric = 1;
54 0           for (1..10) {
55 0 0         last if $_ > @$coldata;
56 0 0         if (!looks_like_number($coldata->[$_-1])) {
57 0           $is_numeric = 0;
58 0           next COL;
59             }
60             }
61 0 0         return $col if $is_numeric;
62             }
63 0           return undef; ## no critic: Subroutines::ProhibitExplicitReturnUndef
64             }
65              
66             sub _find_first_nonnumcol {
67 0     0     my $tbl = shift;
68              
69             COL:
70 0           for my $col (@{ $tbl->cols_by_idx }) {
  0            
71 0           my $coldata = _get_column_data($tbl, $col);
72 0           my $is_nonnum = 1;
73 0           for (1..10) {
74 0 0         last if $_ > @$coldata;
75 0           my $data = $coldata->[$_-1];
76 0 0 0       if (defined($data) && !looks_like_number($data)) {
77 0           $is_nonnum = 0;
78 0           next COL;
79             }
80             }
81 0 0         return $col if $is_nonnum;
82             }
83 0           return undef; ## no critic: Subroutines::ProhibitExplicitReturnUndef
84             }
85              
86             $SPEC{gen_text_chart} = {
87             v => 1.1,
88             summary => "Generate text-based chart",
89             args => {
90             data => {
91             summary => '(Table) data to chart',
92             schema => ['any*', of => [
93             ['array*' => of => 'num*'],
94             ['array*' => of => 'array*'],
95             ['array*' => of => 'hash*'],
96             ]],
97             req => 1,
98             description => <<'MARKDOWN',
99              
100             Either in the form of array of numbers, example:
101              
102             [1366,1248,319,252]
103              
104             or an array of arrays (there must be at least one number columns), example:
105              
106             [["China",1366],["India",1248],["United Status",319], ["Indonesia",252]]
107              
108             or an array of hashes (there must be at least one key which consistently contain
109             numbers), example:
110              
111             [{country=>"China" , population=>1366},
112             {country=>"India" , population=>1248},
113             {country=>"United Status", population=> 319},
114             {country=>"Indonesia" , population=> 252}]
115              
116             All data needs to be in table form (where there are notions of rows and
117             columns/fields). Array data is assumed to be a single-column table with the
118             column named `data`. Array of arrays will have columns named `column0`,
119             `column1` and so on. Array of hashes will have columns named according to the
120             hash keys.
121              
122             MARKDOWN
123             },
124             spec => {
125             summary => 'Table specification, according to TableDef',
126             schema => 'hash*', # XXX TableDef
127             },
128             type => {
129             summary => 'Chart type',
130             schema => ['str*', in => \@CHART_TYPES],
131             req => 1,
132             },
133             label_column => {
134             summary => 'Which column(s) contain data labels',
135             schema => 'str_or_aos1::arrayified',
136             description => <<'MARKDOWN',
137              
138             If not specified, the first non-numeric column will be selected.
139              
140             The number of label columns must match that of data columns.
141              
142             MARKDOWN
143             'x.chart_types' => ['bar'],
144             },
145             data_column => {
146             summary => 'Which column(s) contain data to plot',
147             description => <<'MARKDOWN',
148              
149             Multiple data columns are supported.
150              
151             MARKDOWN
152             schema => 'str_or_aos1::arrayified',
153             },
154             chart_height => {
155             schema => 'float*',
156             'x.chart_types' => ['sparkline'],
157             },
158             chart_width => {
159             schema => 'float*',
160             'x.chart_types' => ['bar'],
161             },
162             show_data_label => {
163             schema => 'bool*',
164             'x.chart_types' => ['bar'],
165             },
166             show_data_value => {
167             schema => 'bool*',
168             'x.chart_types' => ['bar'],
169             },
170             # XXX data_formats
171             # XXX show_x_axis
172             # XXX show_y_axis
173             # XXX data_scale
174             # XXX log_scale
175             },
176             result_naked => 1,
177             result => {
178             schema => 'str*',
179             },
180             };
181             sub gen_text_chart {
182 0     0 1   require Data::TableData::Object;
183              
184 0           my %args = @_;
185             #use DD; dd \%args;
186              
187             # XXX schema
188 0 0         $args{data} or die "Please specify 'data'";
189 0           my $tbl = Data::TableData::Object->new($args{data}, $args{spec});
190              
191 0           my @data_columns;
192             {
193 0           my $dc = $args{data_column};
  0            
194 0 0         if (defined $dc) {
195 0 0         @data_columns = ref($dc) eq 'ARRAY' ? @$dc : ($dc);
196             } else {
197 0           my $col = _find_first_numcol($tbl);
198 0 0         die "There is no numeric column for data" unless defined $col;
199 0           @data_columns = ($col);
200             }
201             }
202              
203 0           my @label_columns;
204             {
205 0           my $lc = $args{label_column};
  0            
206 0 0         if (defined $lc) {
207 0 0         @label_columns = ref($lc) eq 'ARRAY' ? @$lc : ($lc);
208             } else {
209 0           my $col = _find_first_nonnumcol($tbl);
210 0 0         die "There is no non-numeric column for data" unless defined $col;
211 0           @label_columns = ($col);
212             }
213 0 0         if (@label_columns != @data_columns) {
214 0           die "Number of data columns (".scalar(@data_columns).") does not match number of label columns (".scalar(@label_columns).")";
215             }
216             }
217              
218 0           my $buf = "";
219              
220 0 0         my $type = $args{type} or die "Please specify 'type'";
221 0           my $chart_height = $args{chart_height};
222 0           my $chart_width = $args{chart_width};
223              
224 0 0         if ($type eq 'raw') {
    0          
    0          
225              
226 0           my @resrows;
227 0           for my $rowidx (0 .. $tbl->row_count-1) {
228 0           my $resrow = {};
229 0           my $origrow = $tbl->row_as_hos($rowidx);
230 0           for my $i (0 .. @data_columns-1) {
231 0           $resrow->{"data$i"} = $origrow->{$data_columns[$i]};
232 0           $resrow->{"label$i"} = $origrow->{$label_columns[$i]};
233             }
234 0           push @resrows, $resrow;
235             }
236 0           require JSON::MaybeXS;
237 0           $buf = JSON::MaybeXS::encode_json([200, "OK", \@resrows]);
238              
239             } elsif ($type eq 'bar') {
240 0   0       $chart_width //= 75;
241              
242             # calculate maximum label width
243 0           my $max_label_width = 0;
244 0           for my $col (@label_columns) {
245 0   0       my $coldata = [map {$_//''} @{ _get_column_data($tbl, $col) }];
  0            
  0            
246 0           for my $data (@$coldata) {
247 0           my $len = length($data);
248 0 0         $max_label_width = $len if $max_label_width < $len;
249             }
250             }
251              
252             # get maximum value & maximum width for each data column
253 0           my @max; # index: colnum
254 0           my $max_value_width = 0;
255 0           for my $colidx (0 .. @data_columns-1) {
256 0   0       my $coldata = [map {$_//0} @{ _get_column_data($tbl, $data_columns[$colidx]) }];
  0            
  0            
257 0           for my $data (@$coldata) {
258 0 0 0       $max[$colidx] = $data if !defined($max[$colidx]) || $max[$colidx] < $data;
259 0           my $len = length($data);
260 0 0         $max_value_width = $len if $max_value_width < $len;
261             }
262             }
263              
264             my $bar_width = $chart_width
265             - ($args{show_data_label} ? $max_label_width+1 : 0) # "label|"
266 0 0         - ($args{show_data_value} ? $max_value_width+2 : 0) # "(val)"
    0          
267             ;
268 0 0         $bar_width = 1 if $bar_width < 1;
269              
270             # which characters to use to draw:
271 0           my @chars = ('*','=', 'o', 'X', '.', '+', 'x');
272              
273             # draw
274 0           for my $rowidx (0 .. $tbl->row_count-1) {
275 0           my $row = $tbl->row_as_hos($rowidx);
276 0           for my $colidx (0 .. @data_columns-1) {
277 0           my $char = $chars[ $colidx % @chars ];
278 0 0         $buf .= sprintf("%-${max_label_width}s|", $row->{$label_columns[$colidx]}) if $args{show_data_label};
279              
280 0           my $width;
281 0           my $val = $row->{$data_columns[$colidx]};
282 0 0         if (!$max[$colidx]) {
283 0           $width = 0;
284             } else {
285 0           $width = int($bar_width * ($val / $max[$colidx]));
286             }
287 0           $buf .= sprintf("%-${bar_width}s", $char x $width);
288              
289 0 0         $buf .= sprintf("(%${max_value_width}s)", $val) if $args{show_data_value};
290              
291 0           $buf .= "\n";
292             }
293 0 0         $buf .= "\n" if @data_columns > 1;
294             } # for row
295              
296             } elsif ($type eq 'sparkline') {
297 0   0       $chart_height //= 1;
298 0           for my $col (@data_columns) {
299 0   0       my $coldata = [map {$_//0} @{ _get_column_data($tbl, $col) }];
  0            
  0            
300 0           my @dbuf = ( (" " x @$coldata) . "\n" ) x $chart_height;
301 0           my ($min, $max) = minmax(@$coldata);
302 0           my @heights;
303 0           for my $d (@$coldata) {
304 0           my $h;
305 0 0         if ($max != $min) {
306 0           $h = ($d-$min)/($max-$min) * $chart_height;
307             } else {
308 0           $h = 0;
309             }
310 0           push @heights, $h;
311             }
312 0           for my $line (1..$chart_height) {
313 0           my $h1 = $chart_height-$line;
314 0           for my $i (0..@$coldata-1) {
315 0           my $j; # which sparkline character to use
316 0           my $d = $coldata->[$i];
317 0           my $height = $heights[$i];
318 0 0         if ($height > $h1+1) {
    0          
319 0           $j = @sparkline_chars-1; # full
320             } elsif ($height > $h1) {
321 0           $j = sprintf("%.0f", ($height-$h1)*(@sparkline_chars-1));
322             } else {
323             # empty
324 0           next;
325             }
326 0           substr($dbuf[$line-1], $i, 1) = $sparkline_chars[$j];
327             }
328             }
329 0           $buf .= join "", @dbuf;
330             }
331             } else {
332 0           die "Unknown chart type '$type'";
333             }
334              
335 0           $buf;
336             }
337              
338             1;
339             # ABSTRACT: Generate text-based chart
340              
341             __END__