<?php
/**
* Copyright (C) 2009-2022 Graham Breach
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* For more information, please contact <[email protected]>
*/
namespace Goat1000\SVGGraph;
class PieGraph extends Graph {
// for internal use
protected $x_centre;
protected $y_centre;
protected $radius_x;
protected $radius_y;
public $start_angle;
public $end_angle;
protected $s_angle; // start_angle in radians
protected $full_angle; // amount of pie in radians
protected $aspect_ratio;
protected $calc_done;
protected $slice_info = [];
protected $total = 0;
protected $legend_order = [];
protected $dataset = 0;
private $sub_total = 0;
public function __construct($w, $h, array $settings, array $fixed_settings = [])
{
// backwards compatibility
$copy = [
'show_labels' => 'show_data_labels',
'label_fade_in_speed' => 'data_label_fade_in_speed',
'label_fade_out_speed' => 'data_label_fade_out_speed',
];
foreach($copy as $from => $to)
if(isset($settings[$from]) && !isset($settings[$to]))
$settings[$to] = $settings[$from];
$fs = ['repeated_keys' => 'accept'];
$fs = array_merge($fs, $fixed_settings);
parent::__construct($w, $h, $settings, $fs);
}
/**
* Calculates position of pie
*/
protected function calc()
{
$bound_x_left = $this->pad_left;
$bound_y_top = $this->pad_top;
$bound_x_right = $this->width - $this->pad_right;
$bound_y_bottom = $this->height - $this->pad_bottom;
$w = $bound_x_right - $bound_x_left;
$h = $bound_y_bottom - $bound_y_top;
$this->x_centre = (($bound_x_right - $bound_x_left) / 2) + $bound_x_left;
$this->y_centre = (($bound_y_bottom - $bound_y_top) / 2) + $bound_y_top;
$start_angle = $this->getOption('start_angle') % 360;
while($start_angle < 0)
$start_angle += 360;
$this->start_angle = $start_angle;
$this->s_angle = deg2rad($start_angle);
// sanitize aspect ratio
$aspect = $this->getOption('aspect_ratio');
$this->aspect_ratio = ($aspect != 'auto' && $aspect <= 0 ? 1.0 : $aspect);
$end_angle = $this->getOption('end_angle');
if($end_angle === null || !is_numeric($end_angle) ||
$end_angle == $start_angle || abs($end_angle - $start_angle) % 360 == 0) {
$this->full_angle = M_PI * 2.0;
$this->setupAspectRatio($w, $h);
} else {
while($end_angle < $start_angle)
$end_angle += 360;
$this->end_angle = $end_angle;
$this->setOption('end_angle', $end_angle);
$full_angle = $end_angle - $start_angle;
if($full_angle > 360)
$full_angle %= 360;
$this->full_angle = deg2rad($full_angle);
if($this->getOption('slice_fit')) {
// not a full pie, position based on actual shape
$sw = 100;
$sh = 100;
if($this->aspect_ratio != 'auto')
$sw /= $this->aspect_ratio;
$all_slice = new SliceInfo($this->s_angle,
deg2rad($end_angle), $sw, $sh);
$bbox = $all_slice->boundingBox($this->getOption('reverse'));
$bw = $bbox[2] - $bbox[0];
$bh = $bbox[3] - $bbox[1];
$scale_x = $bw / $w;
$scale_y = $bh / $h;
if($this->aspect_ratio == 'auto') {
$this->x_centre = $bound_x_left + ($bbox[0] / -$scale_x);
$this->y_centre = $bound_y_top + ($bbox[1] / -$scale_y);
$w *= $scale_x;
$h *= $scale_y;
$this->aspect_ratio = $bh / $bw;
} else {
// calculate size and position from aspect ratio
$scale_x = $scale_y = max($scale_x, $scale_y);
$bw = ($bbox[2] - $bbox[0]) / $scale_x;
$bh = ($bbox[3] - $bbox[1]) / $scale_y;
$offset_x = $bbox[0] / -$scale_x;
$offset_y = $bbox[1] / -$scale_y;
$this->x_centre = $bound_x_left + ($w - $bw) / 2 + $offset_x;
$this->y_centre = $bound_y_top + ($h - $bh) / 2 + $offset_y;
}
$this->radius_x = $sw / $scale_x;
$this->radius_y = $sh / $scale_y;
} else {
$this->setupAspectRatio($w, $h);
}
}
$this->calc_done = true;
$this->sub_total = 0;
}
/**
* Sets the aspect ratio and radius members
*/
private function setupAspectRatio($w, $h)
{
if($this->aspect_ratio == 'auto')
$this->aspect_ratio = $h/$w;
if($h / $w > $this->aspect_ratio) {
$this->radius_x = $w / 2.0;
$this->radius_y = $this->radius_x * $this->aspect_ratio;
} else {
$this->radius_y = $h / 2.0;
$this->radius_x = $this->radius_y / $this->aspect_ratio;
}
}
/**
* Draws the pie graph
*/
protected function draw()
{
if(!$this->calc_done)
$this->calc();
$min_slice_angle = $this->getOption(['data_label_min_space', $this->dataset]);
$vcount = 0;
// need to store the original position of each value, because the
// sorted list must still refer to the relevant legend entries
$values = [];
foreach($this->values[$this->dataset] as $position => $item) {
$values[] = [$position, $item->value, $item];
if($item->value !== null)
++$vcount;
}
if($this->getOption('sort')) {
uasort($values, function($a, $b) {
return $b[1] - $a[1];
});
}
$body = $this->underShapes();
$slice = 0;
$slices = [];
$slice_no = 0;
$legend_entries = [];
$legend_order = [];
foreach($values as $value) {
// get the original array position of the value
$original_position = $value[0];
$item = $value[2];
$value = $value[1];
$key = $item->key;
$colour_index = $this->getOption('keep_colour_order') ? $original_position : $slice;
if($this->getOption('legend_show_empty') || $item->value != 0) {
$attr = [
'fill' => $this->getColour($item, $colour_index, $this->dataset, false, true)
];
$this->setStroke($attr, $item, $colour_index, $this->dataset, 'round');
// use the original position for legend index
$legend_entries[] = [$original_position, $item, $attr];
$legend_order[] = $original_position;
++$slice;
}
if(!$this->getSliceInfo($slice_no++, $item, $angle_start, $angle_end,
$radius_x, $radius_y))
continue;
// store details for label position and tail
$this->slice_info[$original_position] = new SliceInfo($angle_start,
$angle_end, $radius_x, $radius_y);
// add the data label if the slice angle is big enough
if($this->slice_info[$original_position]->degrees() >= $min_slice_angle) {
$parts = [];
if($this->getOption('show_label_key')) {
$label_key = $this->getKey($this->values->associativeKeys() ?
$original_position : $key);
if($this->getOption('datetime_keys')) {
$number_key = new Number($label_key);
$dtf = new DateTimeFormatter;
$dt = new \DateTime('@' . $number_key);
$label_key = $dtf->format($dt, $this->getOption('data_label_datetime_format'));
}
$parts = explode("\n", $label_key);
}
if($this->getOption('show_label_amount')) {
if($value === null) {
$parts[] = '';
} else {
$num = new Number($value * 1.0, $this->getOption('units_label'),
$this->getOption('units_before_label'));
$parts[] = $num->format();
}
}
if($this->getOption('show_label_percent')) {
$num = new Number($value / $this->total * 100.0, '%');
$parts[] = $num->format($this->getOption('label_percent_decimals'));
}
$label_content = implode("\n", $parts);
$this->addDataLabel($this->dataset, $original_position, $attr, $item,
$this->x_centre, $this->y_centre, 1, 1, $label_content);
}
if($radius_x || $radius_y) {
$this->calcSlice($angle_start, $angle_end, $radius_x, $radius_y,
$x1, $y1, $x2, $y2);
$single_slice = ($vcount == 1) ||
((string)$x1 == (string)$x2 && (string)$y1 == (string)$y2 &&
(string)$angle_start != (string)$angle_end);
if($this->getOption('semantic_classes'))
$attr['class'] = 'series0';
$this_slice = [
'original_position' => $original_position,
'attr' => $attr,
'item' => $item,
'angle_start' => $angle_start,
'angle_end' => $angle_end,
'radius_x' => $radius_x,
'radius_y' => $radius_y,
'single_slice' => $single_slice,
'colour_index' => $colour_index,
];
if($single_slice)
array_unshift($slices, $this_slice);
else
$slices[] = $this_slice;
}
}
// put the slices back in natural order for the legend
usort($legend_entries, function($a, $b) { return $a[0] - $b[0]; });
foreach($legend_entries as $e) {
$this->setLegendEntry(0, $e[0], $e[1], $e[2]);
}
$this->legend_order = $legend_order;
$group = [];
$series = $this->drawSlices($slices);
if($this->getOption('semantic_classes'))
$group['class'] = 'series';
$shadow_id = $this->defs->getShadow();
if($shadow_id !== null)
$group['filter'] = 'url(#' . $shadow_id . ')';
if(!empty($group))
$series = $this->element('g', $group, null, $series);
$body .= $series;
$body .= $this->overShapes();
$extras = $this->pieExtras();
return $body . $extras;
}
/**
* Returns the SVG markup to draw all slices
*/
protected function drawSlices($slice_list)
{
$slices = [];
foreach($slice_list as $slice) {
$item = $slice['item'];
if($this->getOption('show_tooltips'))
$this->setTooltip($slice['attr'], $item, $this->dataset, $item->key,
$item->value, true);
if($this->getOption('show_context_menu'))
$this->setContextMenu($slice['attr'], $this->dataset, $item, true);
$path = $this->getSlice($item,
$slice['angle_start'], $slice['angle_end'],
$slice['radius_x'], $slice['radius_y'],
$slice['attr'], $slice['single_slice'], $slice['colour_index']);
$this_slice = $this->getLink($item, $item->key, $path);
$slices[] = $this_slice;
}
return implode($slices);
}
/**
* Returns a single slice of pie
*/
protected function getSlice($item, $angle_start, $angle_end,
$radius_x, $radius_y, &$attr, $single_slice, $colour_index)
{
$x_start = $y_start = $x_end = $y_end = 0;
$angle_start += $this->s_angle;
$angle_end += $this->s_angle;
$this->calcSlice($angle_start, $angle_end, $radius_x, $radius_y,
$x_start, $y_start, $x_end, $y_end);
if($single_slice && $this->full_angle >= M_PI * 2.0) {
$attr['cx'] = $this->x_centre;
$attr['cy'] = $this->y_centre;
$attr['rx'] = $radius_x;
$attr['ry'] = $radius_y;
return $this->element('ellipse', $attr);
} else {
$outer = ($angle_end - $angle_start > M_PI ? 1 : 0);
$sweep = ($this->getOption('reverse') ? 0 : 1);
$d = new PathData('M', $this->x_centre, $this->y_centre, 'L', $x_start,
$y_start, 'A', $radius_x, $radius_y, 0, $outer, $sweep, $x_end,
$y_end, 'z');
$attr['d'] = $d;
return $this->element('path', $attr);
}
}
/**
* Calculates start and end points of slice
*/
protected function calcSlice($angle_start, $angle_end, $radius_x, $radius_y,
&$x_start, &$y_start, &$x_end, &$y_end)
{
$reverse = $this->getOption('reverse');
$x_start = ($radius_x * cos($angle_start));
$y_start = ($reverse ? -1 : 1) *
($radius_y * sin($angle_start));
$x_end = ($radius_x * cos($angle_end));
$y_end = ($reverse ? -1 : 1) *
($radius_y * sin($angle_end));
$x_start += $this->x_centre;
$y_start += $this->y_centre;
$x_end += $this->x_centre;
$y_end += $this->y_centre;
}
/**
* Finds the angles and radii for a slice
*/
protected function getSliceInfo($num, $item, &$angle_start, &$angle_end,
&$radius_x, &$radius_y)
{
if(!$item->value)
return false;
$unit_slice = $this->full_angle / $this->total;
$angle_start = $this->sub_total * $unit_slice;
$angle_end = ($this->sub_total + $item->value) * $unit_slice;
$radius_x = $this->radius_x;
$radius_y = $this->radius_y;
$this->sub_total += $item->value;
return true;
}
/**
* Checks that the data are valid
*/
protected function checkValues()
{
$this->dataset = $this->getOption(['dataset',0], 0);
parent::checkValues();
if($this->values->getMinValue($this->dataset) < 0)
throw new \Exception('Negative value for pie chart');
$sum = 0;
foreach($this->values[$this->dataset] as $item) {
if($item->value !== null && !is_numeric($item->value))
throw new \Exception('Non-numeric value');
$sum += $item->value;
}
if($sum <= 0)
throw new \Exception('Empty pie chart');
$this->total = $sum;
}
/**
* Returns extra drawing code that goes between pie and labels
*/
protected function pieExtras()
{
return '';
}
/**
* Return box for legend
*/
public function drawLegendEntry($x, $y, $w, $h, $entry)
{
$bar = ['x' => $x, 'y' => $y, 'width' => $w, 'height' => $h];
return $this->element('rect', $bar, $entry->style);
}
/**
* Returns the position for the label and its target
*/
public function dataLabelPosition($dataset, $index, &$item, $x, $y, $w, $h,
$label_w, $label_h)
{
if(isset($this->slice_info[$index])) {
$a = $this->slice_info[$index]->midAngle();
$rx = $this->slice_info[$index]->radius_x;
$ry = $this->slice_info[$index]->radius_y;
// place it at the label_position distance from centre
$pos_radius = $this->getOption('label_position');
$reverse = $this->getOption('reverse');
$ac = $this->s_angle + $a;
$xc = $pos_radius * $rx * cos($ac);
$yc = ($reverse ? -1 : 1) * $pos_radius * $ry * sin($ac);
$pos = new Number($xc) . ' ' . new Number($yc);
if($pos_radius > 1) {
$space = $this->getOption(['data_label_space', $dataset]);
$xt = ($rx + $space) * cos($ac);
$yt = ($reverse ? -1 : 1) * ($ry + $space) * sin($ac);
} else {
$xt = $rx * 0.5 * cos($ac);
$yt = ($reverse ? -1 : 1) * $ry * 0.5 * sin($ac);
}
$target = [$x + $xt, $y + $yt];
} else {
$pos = 'middle centre';
$target = [$x, $y];
}
return [$pos, $target];
}
/**
* Returns the style options for bar labels
*/
public function dataLabelStyle($dataset, $index, &$item)
{
$style = parent::dataLabelStyle($dataset, $index, $item);
// old pie label settings can override global data_label settings
$opts = [
'font' => 'label_font',
'font_size' => 'label_font_size',
'font_weight' => 'label_font_weight',
'colour' => 'label_colour',
'back_colour' => 'label_back_colour',
];
foreach($opts as $key => $opt)
if(isset($this->settings[$opt]))
$style[$key] = $this->settings[$opt];
return $style;
}
/**
* Overload to return the direction of the pie centre
*/
public function dataLabelTailDirection($dataset, $index, $hpos, $vpos)
{
if(isset($this->slice_info[$index])) {
$a = rad2deg($this->slice_info[$index]->midAngle());
// tail direction is opposite slice direction
if($this->getOption('reverse'))
return fmod(900 - $this->start_angle - $a, 360); // 900 == 360 + 360 + 180
else
return fmod(180 + $this->start_angle + $a, 360);
}
// fall back to default
return parent::dataLabelTailDirection($dataset, $index, $hpos, $vpos);
}
/**
* Returns the order that the slices appear in
*/
public function getLegendOrder()
{
return $this->legend_order;
}
}
|