fix data, labels, style draw() option handling to not set state in the
[imager-graph.git] / lib / Imager / Graph / Pie.pm
CommitLineData
35574351
TC
1package Imager::Graph::Pie;
2
3=head1 NAME
4
5 Imager::Graph::Pie - a tool for drawing pie charts on Imager images
6
7=head1 SYNOPSIS
8
9 use Imager::Graph::Pie;
10
11 my $chart = Imager::Graph::Pie->new;
12 # see Imager::Graph for options
81453d28 13 my $img = $chart->draw(
d7fd5863
TC
14 data => [ $first_amount, $second_amount ],
15 size => 350);
35574351
TC
16
17=head1 DESCRIPTION
18
19Imager::Graph::Pie is intender to make it simple to use L<Imager> to
20create good looking pie graphs.
21
22Most of the basic layout and color selection is handed off to
23L<Imager::Graph>.
24
25=over
26
27=cut
28
29use strict;
30use vars qw(@ISA);
31use Imager::Graph;
32@ISA = qw(Imager::Graph);
33use Imager::Graph::Util;
34use POSIX qw(floor);
35
36use constant PI => 3.1415926535;
37
35574351
TC
38=item $graph->draw(...)
39
40Draws a pie graph onto a new image and returns the image.
41
81453d28 42You must at least supply a C<data> parameter and should probably supply a C<labels> parameter. If you supply a C<labels> parameter, you must supply a C<font> parameter.
35574351
TC
43
44The C<data> parameter should be a reference to an array containing the
45data the pie graph should present.
46
47The C<labels> parameter is a reference to an array of labels,
48corresponding to the values in C<data>.
49
50=back
51
52=head1 FEATURES
53
54As described in L<Imager::Graph> you can enable extra features for
55your graph. The features you can use with pie graphs are:
56
57=over
58
59=item legend
60
61adds a legend to your graph. Requires the labels parameter
62
63=item labels
64
65labels each segment of the graph. If the label doesn't fit inside the
66segment it is presented as a callout.
67
68=item labelspc
69
70adds the percentage of the pie to each label.
71
72=item labelspconly
73
74the segments are labels with their percentages only.
75
76=item allcallouts
77
78all labels are presented as callouts
79
35574351
TC
80=item outline
81
82the pie segments are outlined.
83
84=item dropshadow
85
86the pie is given a drop shadow.
87
88=back
89
320f5a49
TC
90=head1 PIE CHART STYLES
91
92The following style values are specific to pie charts:
93
94Controlling callouts, the C<callout> option:
95
96=over
97
98=item *
99
100color - the color of the callout line and the callout text.
101
102=item *
103
104font, size - font and size of the callout text
105
106=item *
107
108outside - the distance the radial callout line goes outside the pie
109
110=item *
111
112leadlen - the length of the horizontal callout line from the end of
113the radial line.
114
115=item *
116
117gap - the distance between the end of the horizontal callout line and
118the label.
119
120=item *
121
122inside - the length of the radial callout line within the pie.
123
124=back
125
126The outline, line option controls the color of the pie segment
127outlines, if enabled with the C<outline> feature.
128
129Under C<pie>:
130
131=over
132
133=item *
134
135maxsegment - any segment below this fraction of the total of the
136segments will be put into the "others" segment. Default: 0.01
137
138=back
139
140The top level C<otherlabel> setting controls the label for the
141"others" segment, default "(others)".
142
35574351
TC
143=head1 EXAMPLES
144
145Assuming:
146
147 # from the Netcraft September 2001 web survey
148 # http://www.netcraft.com/survey/
149 my @data = qw(17874757 8146372 1321544 811406 );
150 my @labels = qw(Apache Microsoft iPlanet Zeus );
151
152 my $pie = Imager::Graph::Pie->new;
153
154First a simple graph, normal size, no labels:
155
156 my $img = $pie->draw(data=>\@data)
157 or die $pie->error;
158
159label the segments:
160
161 # error handling omitted for brevity from now on
162 $img = $pie->draw(data=>\@data, labels=>\@labels, features=>'labels');
163
164just percentages in the segments:
165
166 $img = $pie->draw(data=>\@data, features=>'labelspconly');
167
168add a legend as well:
169
170 $img = $pie->draw(data=>\@data, labels=>\@labels,
171 features=>[ 'labelspconly', 'legend' ]);
172
173and a title, but move the legend down, and add a dropshadow:
174
175 $img = $pie->draw(data=>\@data, labels=>\@labels,
176 title=>'Netcraft Web Survey',
177 legend=>{ valign=>'bottom' },
178 features=>[ qw/labelspconly legend dropshadow/ ]);
179
180something a bit prettier:
181
35574351
TC
182 $img = $pie->draw(data=>\@data, labels=>\@labels,
183 style=>'fount_lin', features=>'legend');
184
185suitable for monochrome output:
186
35574351
TC
187 $img = $pie->draw(data=>\@data, labels=>\@labels,
188 style=>'mono', features=>'legend');
189
190=cut
191
192# this function is too long
193sub draw {
194 my ($self, %opts) = @_;
195
a17e870a 196 my $data_series = $self->_getDataSeries(\%opts);
35574351 197
a17e870a
TC
198 $self->_validInput($data_series)
199 or return;
dfd889da 200
a17e870a 201 my @data = @{$data_series->[0]->{'data'}};
dfd889da 202
a17e870a 203 my @labels = @{$self->_getLabels(\%opts) || []};
d7fd5863 204
35574351
TC
205 $self->_style_setup(\%opts);
206
207 my $style = $self->{_style};
208
209 my $img = $self->_make_img()
210 or return;
211
35574351
TC
212 my @chart_box = ( 0, 0, $img->getwidth-1, $img->getheight-1 );
213 if ($style->{title}{text}) {
214 $self->_draw_title($img, \@chart_box)
215 or return;
216 }
217
dfd889da 218 my $total = 0;
219 for my $item (@data) {
220 $total += $item;
221 }
222
35574351
TC
223 # consolidate any segments that are too small to display
224 $self->_consolidate_segments(\@data, \@labels, $total);
225
dfd889da 226 if ($style->{features}{legend} && (scalar @labels)) {
35574351
TC
227 $self->_draw_legend($img, \@labels, \@chart_box)
228 or return;
229 }
230
231 # the following code is fairly ugly
232 # it attempts to work out a good layout for the components of the chart
233 my @info;
234 my $index = 0;
235 my $pos = 0;
236 my @ebox = (0, 0, 0, 0);
237 defined(my $callout_outside = $self->_get_number('callout.outside'))
238 or return;
239 defined(my $callout_leadlen = $self->_get_number('callout.leadlen'))
240 or return;
241 defined(my $callout_gap = $self->_get_number('callout.gap'))
242 or return;
243 defined(my $label_vpad = $self->_get_number('label.vpad'))
244 or return;
245 defined(my $label_hpad = $self->_get_number('label.hpad'))
246 or return;
247 my $guessradius =
248 int($self->_small_extent(\@chart_box) * $style->{pie}{guessfactor} * 0.5);
249 for my $data (@data) {
250 my $item = { data=>$data, index=>$index };
251 my $size = 2 * PI * $data / $total;
252 $item->{begin} = $pos;
253 $pos += $size;
254 $item->{end} = $pos;
dfd889da 255 if (scalar @labels) {
35574351
TC
256 $item->{text} = $labels[$index];
257 }
258 if ($style->{features}{labelspconly}) {
259 $item->{text} =
260 $style->{label}{pconlyformat}->($data/$total * 100);
261 }
262 if ($item->{text}) {
263 if ($style->{features}{labelspc}) {
264 $item->{text} =
265 $style->{label}{pcformat}->($item->{text}, $data/$total * 100);
266 $item->{label} = 1;
267 }
268 elsif ($style->{features}{labelspconly}) {
269 $item->{text} =
270 $style->{label}{pconlyformat}->($data/$total * 100);
271 $item->{label} = 1;
272 }
273 elsif ($style->{features}{labels}) {
274 $item->{label} = 1;
275 }
d7fd5863
TC
276 $item->{callout} = 1 if $style->{features}{allcallouts};
277 if (!$item->{callout}) {
278 my @lbox = $self->_text_bbox($item->{text}, 'label')
279 or return;
280 $item->{lbox} = \@lbox;
281 if ($item->{label}) {
282 unless ($self->_fit_text(0, 0, 'label', $item->{text}, $guessradius,
283 $item->{begin}, $item->{end})) {
284 $item->{callout} = 1;
285 }
35574351
TC
286 }
287 }
35574351
TC
288 if ($item->{callout}) {
289 $item->{label} = 0;
d7fd5863
TC
290 my @cbox = $self->_text_bbox($item->{text}, 'callout')
291 or return;
292 $item->{cbox} = \@cbox;
293 $item->{cangle} = ($item->{begin} + $item->{end}) / 2;
294 my $dist = cos($item->{cangle}) * ($guessradius+
35574351 295 $callout_outside);
d7fd5863
TC
296 my $co_size = $callout_leadlen + $callout_gap + $item->{cbox}[2];
297 if ($dist < 0) {
298 $dist -= $co_size - $guessradius;
299 $dist < $ebox[0] and $ebox[0] = $dist;
300 }
301 else {
302 $dist += $co_size - $guessradius;
303 $dist > $ebox[2] and $ebox[2] = $dist;
304 }
35574351
TC
305 }
306 }
307 push(@info, $item);
308 ++$index;
309 }
310
311 my $radius =
312 int($self->_small_extent(\@chart_box) * $style->{pie}{size} * 0.5);
313 my $max_width = $chart_box[2] - $chart_box[0] + $ebox[0] - $ebox[2];
314 if ($radius > $max_width / 2) {
3c9a5609 315 $radius = int($max_width / 2);
35574351
TC
316 }
317 $chart_box[0] -= $ebox[0];
318 $chart_box[2] -= $ebox[2];
319 my $cx = int(($chart_box[0] + $chart_box[2]) / 2);
320 my $cy = int(($chart_box[1] + $chart_box[3]) / 2);
321 if ($style->{features}{dropshadow}) {
322 my @shadow_fill = $self->_get_fill('dropshadow.fill')
323 or return;
324 my $offx = $self->_get_number('dropshadow.offx')
325 or return;
326 my $offy = $self->_get_number('dropshadow.offy');
327 for my $item (@info) {
bfcf9414 328 $img->arc(x=>$cx+$offx, 'y'=>$cy+$offy, r=>$radius+1, aa => 1,
35574351
TC
329 d1=>180/PI * $item->{begin}, d2=>180/PI * $item->{end},
330 @shadow_fill);
331 }
332 $self->_filter_region($img,
333 $cx+$offx-$radius-10, $cy+$offy-$radius-10,
334 $cx+$offx+$radius+10, $cy+$offy+$radius+10,
335 'dropshadow.filter')
336 if $style->{dropshadow}{filter};
337 }
bfcf9414 338
35574351
TC
339 my @fill_box = ( $cx-$radius, $cy-$radius, $cx+$radius, $cy+$radius );
340 for my $item (@info) {
26c93f46
TC
341 $item->{begin} < $item->{end}
342 or next;
35574351
TC
343 my @fill = $self->_data_fill($item->{index}, \@fill_box)
344 or return;
bfcf9414 345 $img->arc(x=>$cx, 'y'=>$cy, r=>$radius, aa => 1,
35574351
TC
346 d1=>180/PI * $item->{begin}, d2=>180/PI * $item->{end},
347 @fill);
348 }
35574351
TC
349 if ($style->{features}{outline}) {
350 my $outcolor = $self->_get_color('outline.line');
351 for my $item (@info) {
bfcf9414
TC
352 my $px = int($cx + $radius * cos($item->{begin}));
353 my $py = int($cy + $radius * sin($item->{begin}));
26c93f46 354 $item->{begin} < $item->{end}
d7fd5863 355 or next;
35574351
TC
356 $img->line(x1=>$cx, y1=>$cy, x2=>$px, y2=>$py, color=>$outcolor);
357 for (my $i = $item->{begin}; $i < $item->{end}; $i += PI/180) {
d7fd5863
TC
358 my $stroke_end = $i + PI/180;
359 $stroke_end = $item->{end} if $stroke_end > $item->{end};
360 my $nx = int($cx + $radius * cos($stroke_end));
361 my $ny = int($cy + $radius * sin($stroke_end));
362 $img->line(x1=>$px, y1=>$py, x2=>$nx, y2=>$ny, color=>$outcolor,
363 antialias=>1);
364 ($px, $py) = ($nx, $ny);
35574351
TC
365 }
366 }
367 }
368
369 my $callout_inside = $radius - $self->_get_number('callout.inside');
370 $callout_outside += $radius;
d7fd5863
TC
371 my %callout_text;
372 my %label_text;
35574351 373 for my $label (@info) {
d7fd5863
TC
374 if ($label->{label} && !$label->{callout}) {
375 # at this point we know we need the label font, to calculate
376 # whether the label will fit if anything else
377 unless (%label_text) {
378 %label_text = $self->_text_style('label')
379 or return;
380 }
35574351
TC
381 my @loc = $self->_fit_text($cx, $cy, 'label', $label->{text}, $radius,
382 $label->{begin}, $label->{end});
383 if (@loc) {
384 my $tcx = ($loc[0]+$loc[2])/2;
385 my $tcy = ($loc[1]+$loc[3])/2;
386 #$img->box(xmin=>$loc[0], ymin=>$loc[1], xmax=>$loc[2], ymax=>$loc[3],
387 # color=>Imager::Color->new(0,0,0));
388 $img->string(%label_text, x=>$tcx-$label->{lbox}[2]/2,
389 'y'=>$tcy+$label->{lbox}[3]/2+$label->{lbox}[1],
390 text=>$label->{text});
391 }
392 else {
393 $label->{callout} = 1;
d7fd5863
TC
394 my @cbox = $self->_text_bbox($label->{text}, 'callout')
395 or return;
5d622bb8 396 $label->{cbox} = \@cbox;
35574351
TC
397 $label->{cangle} = ($label->{begin} + $label->{end}) / 2;
398 }
399 }
400 if ($label->{callout}) {
d7fd5863
TC
401 unless (%callout_text) {
402 %callout_text = $self->_text_style('callout')
403 or return;
404 }
35574351
TC
405 my $ix = floor(0.5 + $cx + $callout_inside * cos($label->{cangle}));
406 my $iy = floor(0.5 + $cy + $callout_inside * sin($label->{cangle}));
407 my $ox = floor(0.5 + $cx + $callout_outside * cos($label->{cangle}));
408 my $oy = floor(0.5 + $cy + $callout_outside * sin($label->{cangle}));
409 my $lx = ($ox < $cx) ? $ox - $callout_leadlen : $ox + $callout_leadlen;
410 $img->line(x1=>$ix, y1=>$iy, x2=>$ox, y2=>$oy, antialias=>1,
d7fd5863 411 color=>$self->_get_color('callout.color'));
35574351 412 $img->line(x1=>$ox, y1=>$oy, x2=>$lx, y2=>$oy, antialias=>1,
d7fd5863 413 color=>$self->_get_color('callout.color'));
35574351
TC
414 #my $tx = $lx + $callout_gap;
415 my $ty = $oy + $label->{cbox}[3]/2+$label->{cbox}[1];
416 if ($lx < $cx) {
d7fd5863
TC
417 $img->string(%callout_text, x=>$lx-$callout_gap-$label->{cbox}[2],
418 'y'=>$ty, text=>$label->{text});
35574351
TC
419 }
420 else {
d7fd5863
TC
421 $img->string(%callout_text, x=>$lx+$callout_gap, 'y'=>$ty,
422 text=>$label->{text});
35574351
TC
423 }
424 }
425 }
426
427 $img;
428}
429
dfd889da 430sub _validInput {
a17e870a 431 my ($self, $data_series) = @_;
dfd889da 432
a17e870a 433 if (!defined $data_series || !scalar @$data_series) {
dfd889da 434 return $self->_error("No data supplied");
435 }
436
a17e870a
TC
437 @$data_series == 1
438 or return $self->_error("Pie charts only allow one data series");
439
440 my $data = $data_series->[0]{data};
441
442 if (!scalar @$data) {
dfd889da 443 return $self->_error("No values in data series");
444 }
445
dfd889da 446 my $total = 0;
447 {
448 my $index = 0;
a17e870a 449 for my $item (@$data) {
dfd889da 450 $item < 0
451 and return $self->_error("Data index $index is less than zero");
452
453 $total += $item;
454
455 ++$index;
456 }
457 }
458 $total == 0
459 and return $self->_error("Sum of all data values is zero");
460
461 return 1;
462}
463
35574351
TC
464=head1 INTERNAL FUNCTIONS
465
466These are used in the implementation of Imager::Graph, and are
467documented for debuggers and developers.
468
469=over
470
471=item _consolidate_segments($data, $labels, $total)
472
473Consolidate segments that are too small into an 'others' segment.
474
475=cut
476
477sub _consolidate_segments {
478 my ($self, $data, $labels, $total) = @_;
479
480 my @others;
481 my $index;
482 for my $item (@$data) {
483 if ($item / $total < $self->{_style}{pie}{maxsegment}) {
484 push(@others, $index);
485 }
486 ++$index;
487 }
488 if (@others) {
489 my $others = 0;
490 for my $index (reverse @others) {
491 $others += $data->[$index];
492 splice(@$labels, $index, 1);
493 splice(@$data, $index, 1);
494 }
495 push(@$labels, $self->{_style}{otherlabel}) if @$labels;
496 push(@$data, $others);
497 }
498}
499
35574351
TC
500# used for debugging
501sub _test_line {
502 my ($x, $y, @l) = @_;
503
504 my $res = $l[0]*$x + $l[1] * $y + $l[2];
505 print "test ", (abs($res) < 0.000001) ? "success\n" : "failure $res\n";
506}
507
508=item _fit_text($cx, $cy, $name, $text, $radius, $begin, $end)
509
510Attempts to fit text into a pie segment with its center at ($cx, $cy)
511with the given radius, covering the angles $begin through $end.
512
513Returns a list defining the bounding box of the text if it does fit.
514
515=cut
516
517sub _fit_text {
518 my ($self, $cx, $cy, $name, $text, $radius, $begin, $end) = @_;
519
520 #print "fit: $cx, $cy '$text' $radius $begin $end\n";
d7fd5863
TC
521 my @tbox = $self->_text_bbox($text, $name)
522 or return;
35574351
TC
523 my $tcx = floor(0.5+$cx + cos(($begin+$end)/2) * $radius *3/5);
524 my $tcy = floor(0.5+$cy + sin(($begin+$end)/2) * $radius *3/5);
525 my $topy = $tcy - $tbox[3]/2;
526 my $boty = $topy + $tbox[3];
527 my @lines;
528 for my $y ($topy, $boty) {
529 my %entry = ( 'y'=>$y );
530 $entry{line} = [ line_from_points($tcx, $y, $tcx+1, $y) ];
531 $entry{left} = -$radius;
532 $entry{right} = $radius;
533 for my $angle ($begin, $end) {
534 my $ex = $cx + cos($angle)*$radius;
535 my $ey = $cy + sin($angle)*$radius;
536 my @line = line_from_points($cx, $cy, $ex, $ey);
537 #_test_line($cx, $cy, @line);
538 #_test_line($ex, $ey, @line);
539 my $goodsign = $line[0] * $tcx + $line[1] * $tcy + $line[2];
540 for my $pos (@entry{qw/left right/}) {
541 my $sign = $line[0] * ($pos+$tcx) + $line[1] * $y + $line[2];
542 if ($goodsign * $sign < 0) {
543 if (my @p = intersect_lines(@line, @{$entry{line}})) {
544 # die "$goodsign $sign ($pos, $tcx) no intersect (@line) (@{$entry{line}})" ; # this would be wierd
545 #_test_line(@p, @line);
546 #_test_line(@p, @{$entry{line}});
547 $pos = $p[0]-$tcx;
548 }
549 else {
550 return;
551 }
552
553 }
554
555 # circle
556 my $dist2 = ($pos+$tcx-$cx) * ($pos+$tcx-$cx)
557 + ($y - $cy) * ($y - $cy);
558 if ($dist2 > $radius * $radius) {
559 my @points =
560 intersect_line_and_circle(@{$entry{line}}, $cx, $cy, $radius);
561 while (@points) {
562 my @p = splice(@points, 0, 2);
563 if ($p[0] < $cx && $tcx+$pos < $p[0]) {
564 $pos = $p[0]-$tcx;
565 }
566 elsif ($p[0] > $cx && $tcx+$pos > $p[0]) {
567 $pos = $p[0]-$tcx;
568 }
569 }
570 }
571 }
572 }
573 push(@lines, \%entry);
574 }
575 my $left = $lines[0]{left} > $lines[1]{left} ? $lines[0]{left} : $lines[1]{left};
576 my $right = $lines[0]{right} < $lines[1]{right} ? $lines[0]{right} : $lines[1]{right};
577 return if $right - $left < $tbox[2];
578
579 return ($tcx+$left, $topy, $tcx+$right, $boty);
580}
581
582sub _composite {
583 ( 'pie', $_[0]->SUPER::_composite() );
584}
585
586sub _style_defs {
587 my ($self) = @_;
588
589 my %work = %{$self->SUPER::_style_defs()};
590 $work{otherlabel} = "(others)";
35574351
TC
591 $work{pie} =
592 {
35574351
TC
593 guessfactor=>0.6,
594 size=>0.8,
320f5a49 595 maxsegment=> 0.01,
35574351
TC
596 };
597
598 \%work;
599}
600
6011;
602__END__
603
54ada35d
TC
604=back
605
35574351
TC
606=head1 AUTHOR
607
608Tony Cook <tony@develop-help.com>
609
610=head1 SEE ALSO
611
612Imager::Graph(3), Imager(3), perl(1)
613
614=cut