. */ /** * For more information, please contact */ 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; } }