<?php
/**
* Copyright (C) 2012-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;
/**
* RadarGraph - a line graph that goes around in circles
*/
class RadarGraph extends LineGraph {
protected $xc;
protected $yc;
protected $radius;
protected $arad;
public function __construct($w, $h, array $settings, array $fixed_settings = [])
{
// in the case of radar graphs, $label_centre means we want an axis that
// ends at N points + 1
$fs = [
'label_centre' => true,
'single_axis' => true,
];
$fs = array_merge($fs, $fixed_settings);
parent::__construct($w, $h, $settings, $fs);
}
protected function draw()
{
$body = $this->grid() . $this->underShapes();
$bnum = 0;
$cmd = 'M';
$y_axis = $this->y_axes[$this->main_y_axis];
$dataset = $this->getOption(['dataset', 0], 0);
$graph_line = '';
$line_breaks = $this->getOption(['line_breaks', $dataset]);
$points = [];
$first_point = null;
foreach($this->values[$dataset] as $item) {
if($line_breaks && $item->value === null && count($points) > 0) {
$graph_line .= $this->drawLine($dataset, $points, 0);
$points = [];
} else {
$point_pos = $this->gridPosition($item, $bnum);
if($item->value !== null && $point_pos !== null) {
$val = $y_axis->position($item->value);
$angle = $this->arad + $point_pos / $this->g_height;
$x = $this->xc + ($val * sin($angle));
$y = $this->yc + ($val * cos($angle));
$points[] = [$x, $y, $item, $dataset, $bnum];
if($first_point === null)
$first_point = $points[0];
}
}
++$bnum;
}
// close graph or segment?
if($first_point && (!$line_breaks || $first_point[4] == 0)) {
$first_point[2] = null;
$points[] = $first_point;
}
$graph_line .= $this->drawLine($dataset, $points, 0);
$group = [];
if($this->getOption('semantic_classes'))
$group['class'] = 'series';
$graph_line = $this->element('g', $group, null, $graph_line);
$group = [];
$shadow_id = $this->defs->getShadow();
if($shadow_id !== null)
$group['filter'] = 'url(#' . $shadow_id . ')';
if(!empty($group))
$graph_line = $this->element('g', $group, null, $graph_line);
$body .= $graph_line;
$body .= $this->overShapes();
$body .= $this->axes();
$body .= $this->drawMarkers();
return $body;
}
/**
* Fill from centre point
*/
protected function fillFrom(Number $x, Number $y)
{
return new PathData('M', $this->xc, $this->yc);
}
/**
* Fill to centre point
*/
protected function fillTo(Number $x, Number $y)
{
return new PathData('L', $this->xc, $this->yc, 'z');
}
/**
* Finds the grid position for radar graphs, returns NULL if not on graph
*/
protected function gridPosition($item, $ikey)
{
$gkey = $this->values->associativeKeys() ? $ikey : $item->key;
$axis = $this->x_axes[$this->main_x_axis];
$offset = $axis->position($gkey);
if($offset >= 0 && $offset < $this->g_width)
return $this->getOption('reverse') ? -$offset : $offset;
return null;
}
/**
* Returns the $x value as a grid position
*/
public function gridX($x, $axis_no = null)
{
$p = $this->unitsX($x, $axis_no);
return $p;
}
/**
* Returns the $y value as a grid position
*/
public function gridY($y, $axis_no = null)
{
$p = $this->unitsY($y, $axis_no);
return $p;
}
/**
* Returns a DisplayAxisRadar for the round axis
*/
protected function getDisplayAxis($axis, $axis_no, $orientation, $type)
{
$var = 'main_' . $type . '_axis';
$main = ($axis_no == $this->{$var});
if($orientation == 'h')
return new DisplayAxisRadar($this, $axis, $axis_no, $orientation, $type,
$main, $this->xc, $this->yc, $this->radius);
return new DisplayAxisRotated($this, $axis, $axis_no, $orientation, $type,
$main, $this->arad);
}
/**
* Returns the location of an axis
*/
protected function getAxisLocation($orientation, $axis_no)
{
return [$this->xc, $this->yc];
}
/**
* Convert X, Y in grid space to radial position
*/
public function transformCoords($x, $y)
{
$angle = $x / $this->g_height;
if($this->getOption('grid_straight')) {
// this complicates things...
// get all the grid points, div and subdiv
$points = array_merge($this->getGridPointsX(0), $this->getSubDivsX(0));
$grid_angles = [];
foreach($points as $point) {
$grid_angles[] = $point->position / $this->radius;
}
sort($grid_angles);
// find angle between (sub)divisions
$div_angle = $grid_angles[1] - $grid_angles[0];
// use trig to find length of Y
$a = fmod($angle, $div_angle);
$t = ($div_angle / 2) - $a;
$y2 = $y * cos($div_angle / 2);
$y = $y2 / cos($t);
}
$angle += $this->arad;
$new_x = $this->xc + ($y * sin($angle));
$new_y = $this->yc + ($y * cos($angle));
return [$new_x, $new_y];
}
/**
* Find the bounding box of the axis text for given axis lengths
*/
protected function findAxisBBox($length_x, $length_y, $x_axes, $y_axes)
{
$this->xc = $length_x / 2;
$this->yc = $length_y / 2;
$diameter = min($length_x, $length_y);
$length_y = $diameter / 2;
$length_x = 2 * M_PI * $length_y;
$this->radius = $length_y;
foreach($x_axes as $a)
$a->setLength($length_x);
foreach($y_axes as $a)
$a->setLength($length_y);
$bb = new BoundingBox($this->width, $this->height, 0, 0);
$display_axis = $this->getDisplayAxis($x_axes[0], 0, 'h', 'x');
$axis_m = $display_axis->measure();
$bb->grow($axis_m->x1, $axis_m->y1, $axis_m->x2, $axis_m->y2);
$display_axis = $this->getDisplayAxis($y_axes[0], 0, 'v', 'y');
$axis_m = $display_axis->measure();
$bb->grow($axis_m->x1 + $this->xc, $axis_m->y1 + $this->yc,
$axis_m->x2 + $this->xc, $axis_m->y2 + $this->yc);
$this->radius = null;
return [
'min_x' => $bb->x1, 'min_y' => $bb->y1,
'max_x' => $bb->x2, 'max_y' => $bb->y2
];
}
/**
* Draws concentric Y grid lines
*/
public function yGrid(&$y_points)
{
$path = new PathData;
if($this->getOption('grid_straight')) {
$grid_angles = [];
$points = array_merge($this->getGridPointsX(0), $this->getSubDivsX(0));
foreach($points as $point) {
$new_x = $point->position - $this->pad_left;
$grid_angles[] = $this->arad + $new_x / $this->radius;
}
// put the grid angles in order
sort($grid_angles);
foreach($y_points as $y) {
$y = $y->position;
$x1 = $this->xc + $y * sin($this->arad);
$y1 = $this->yc + $y * cos($this->arad);
$path->add('M', $x1, $y1, 'L');
foreach($grid_angles as $a) {
$x1 = $this->xc + $y * sin($a);
$y1 = $this->yc + $y * cos($a);
$path->add($x1, $y1);
}
$path->add('z');
}
} else {
$yc_num = new Number($this->yc);
foreach($y_points as $y) {
$y = $y->position;
$p1 = new Number($this->xc - $y);
$p2 = new Number($this->xc + $y);
$y = new Number($y);
$path->add('M', $p1, $yc_num, 'A', $y, $y, 0, 1, 1, $p2, $yc_num);
$path->add('M', $p2, $yc_num, 'A', $y, $y, 0, 1, 1, $p1, $yc_num);
}
}
return $path;
}
/**
* Draws radiating X grid lines
*/
protected function xGrid(&$x_points)
{
$path = new PathData;
$xc_num = new Number($this->xc);
$yc_num = new Number($this->yc);
foreach($x_points as $x) {
$x = $x->position - $this->pad_left;
$angle = $this->arad + $x / $this->radius;
$p1 = $this->radius * sin($angle);
$p2 = $this->radius * cos($angle);
$path->add('M', $xc_num, $yc_num, 'l', $p1, $p2);
}
return $path;
}
/**
* Draws the grid behind the graph
*/
protected function grid()
{
$this->calcAxes();
$this->calcGrid();
if(!$this->getOption('show_grid') ||
(!$this->getOption('show_grid_h') && !$this->getOption('show_grid_v')))
return '';
$back = $subpath = '';
$back_colour = new Colour($this, $this->getOption('grid_back_colour'),
true, true, true);
$y_points = $this->getGridPointsY(0);
$x_points = $this->getGridPointsX(0);
$y_subdivs = $this->getSubDivsY(0);
$x_subdivs = $this->getSubDivsX(0);
if(!$back_colour->isNone()) {
// use the YGrid function to get the path
$points = [new GridPoint($this->radius, '', 0)];
$bpath = [
'd' => $this->yGrid($points),
'fill' => $back_colour
];
if($this->getOption('grid_back_opacity') != 1)
$bpath['fill-opacity'] = $this->getOption('grid_back_opacity');
$back = $this->element('path', $bpath);
}
if($this->getOption('grid_back_stripe')) {
// use array of colours if available, otherwise stripe a single colour
$colours = is_array($this->getOption('grid_back_stripe_colour')) ?
$this->getOption('grid_back_stripe_colour') :
[null, $this->getOption('grid_back_stripe_colour')];
$c = 0;
$num_colours = count($colours);
$num_points = count($y_points);
while($c < $num_points - 1) {
$cc = $colours[$c % $num_colours];
if($cc !== null) {
$s_points = [$y_points[$c], $y_points[$c + 1]];
$c1 = new Colour($this, $cc);
if($c1->isGradient())
$c1 = $this->remapGradient($cc, $s_points);
$bpath = [
'fill' => $c1,
'd' => $this->yGrid($s_points),
'fill-rule' => 'evenodd',
];
if($this->getOption('grid_back_stripe_opacity') != 1)
$bpath['fill-opacity'] = $this->getOption('grid_back_stripe_opacity');
$back .= $this->element('path', $bpath);
}
++$c;
}
}
if($this->getOption('show_grid_subdivisions')) {
$subpath_h = new PathData;
$subpath_v = new PathData;
if($this->getOption('show_grid_h'))
$subpath_h = $this->yGrid($y_subdivs);
if($this->getOption('show_grid_v'))
$subpath_v = $this->xGrid($x_subdivs);
if(!($subpath_h->isEmpty() && $subpath_v->isEmpty())) {
$colour_h = $this->getOption('grid_subdivision_colour_h',
'grid_subdivision_colour', 'grid_colour_h', 'grid_colour');
$colour_v = $this->getOption('grid_subdivision_colour_v',
'grid_subdivision_colour', 'grid_colour_v', 'grid_colour');
$dash_h = $this->getOption('grid_subdivision_dash_h',
'grid_subdivision_dash', 'grid_dash_h', 'grid_dash');
$dash_v = $this->getOption('grid_subdivision_dash_v',
'grid_subdivision_dash', 'grid_dash_v', 'grid_dash');
$width_h = $this->getOption('grid_subdivision_stroke_width_h',
'grid_subdivision_stroke_width', 'grid_stroke_width_h',
'grid_stroke_width');
$width_v = $this->getOption('grid_subdivision_stroke_width_v',
'grid_subdivision_stroke_width', 'grid_stroke_width_v',
'grid_stroke_width');
if($dash_h == $dash_v && $colour_h == $colour_v && $width_h == $width_v) {
$subpath_h->add($subpath_v);
$subpath = $this->gridLines($subpath_h, $colour_h, $dash_h, $width_h,
['fill' => 'none']);
} else {
$subpath = $this->gridLines($subpath_h, $colour_h, $dash_h, $width_h,
['fill' => 'none']) .
$this->gridLines($subpath_v, $colour_v, $dash_v, $width_v,
['fill' => 'none']);
}
}
}
$path_v = new PathData;
$path_h = new PathData;
if($this->getOption('show_grid_h'))
$path_v = $this->yGrid($y_points);
if($this->getOption('show_grid_v'))
$path_h = $this->xGrid($x_points);
$colour_h = $this->getOption('grid_colour_h', 'grid_colour');
$colour_v = $this->getOption('grid_colour_v', 'grid_colour');
$dash_h = $this->getOption('grid_dash_h', 'grid_dash');
$dash_v = $this->getOption('grid_dash_v', 'grid_dash');
$width_h = $this->getOption('grid_stroke_width_h', 'grid_stroke_width');
$width_v = $this->getOption('grid_stroke_width_v', 'grid_stroke_width');
if($dash_h == $dash_v && $colour_h == $colour_v && $width_h == $width_v) {
$path_v->add($path_h);
$path = $this->gridLines($path_v, $colour_h, $dash_h, $width_h,
['fill' => 'none']);
} else {
$path = $this->gridLines($path_h, $colour_h, $dash_h, $width_h,
['fill' => 'none']) .
$this->gridLines($path_v, $colour_v, $dash_v, $width_v,
['fill' => 'none']);
}
return $back . $subpath . $path;
}
/**
* Sets the grid size as circumference x radius
*/
protected function setGridDimensions()
{
if($this->radius === null) {
$w = $this->width - $this->pad_left - $this->pad_right;
$h = $this->height - $this->pad_top - $this->pad_bottom;
$this->xc = $this->pad_left + $w / 2;
$this->yc = $this->pad_top + $h / 2;
$this->radius = min($w, $h) / 2;
}
$this->g_height = $this->radius;
$this->g_width = 2 * M_PI * $this->radius;
}
/**
* Calculate the extra details for radar axes
*/
protected function calcAxes()
{
$this->arad = (90 + $this->getOption('start_angle')) * M_PI / 180;
$this->setOption('axis_right', false);
parent::calcAxes();
}
/**
* Returns the grid points for a Y-axis
*/
protected function getGridPointsY($axis)
{
$points = $this->y_axes[$axis]->getGridPoints(0);
foreach($points as $k => $p)
$points[$k]->position = -$p->position;
return $points;
}
/**
* Returns the subdivisions for a Y-axis
*/
protected function getSubDivsY($axis)
{
$points = $this->y_axes[$axis]->getGridSubdivisions(
$this->getOption('minimum_subdivision'),
$this->getOption(['minimum_units_y', $axis]), 0,
$this->getOption(['subdivision_v', $axis]));
foreach($points as $k => $p)
$points[$k]->position = -$p->position;
return $points;
}
/**
* Both of these functions need to exist, just call one from the other
*/
public function guidelinePathAbove($axis, $value, &$x, &$y, &$w, &$h,
$reverse_length)
{
return $this->guidelinePathBelow($axis, $value, $x, $y, $w, $h, $reverse_length);
}
/**
* Draws the path for a guideline
*/
public function guidelinePathBelow($axis, $value, &$x, &$y, &$w, &$h,
$reverse_length)
{
$coords = new Coords($this);
$path = new PathData;
$grid_value = 'g' . (is_numeric($value) ? new Number($value) : $value);
if($axis == 'x') {
// radial line
$x = $coords->transform($grid_value, 'x', null);
if($x === null)
return $path;
if(is_string($h) || $h > 0) {
$h = $coords->transform($h, 'y');
} else {
$h = $this->radius;
}
if($reverse_length) {
list($p1x, $p1y) = $this->transformCoords($x, $this->radius - $h);
$h = $this->radius;
} else {
$p1x = $this->xc;
$p1y = $this->yc;
}
list($p2x, $p2y) = $this->transformCoords($x, $h);
$path->add('M', $p1x, $p1y, 'L', $p2x, $p2y);
// text position values - not very useful though
$x = min($p1x, $p2x);
$y = min($p1y, $p2y);
$w = abs($p2x - $p1x);
$h = abs($p2y - $p1y);
} else {
$x = $coords->transform('gl', 'x');
$y = $coords->transform($grid_value, 'y', null);
if($y === null)
return $path;
if(is_string($w) || $w > 0) {
$w = $coords->transform($w, 'x');
} else {
$w = $coords->transform('gw', 'x');
}
if($reverse_length)
$x = $coords->transform('gr', 'x') - $w;
$h = 0;
// round or segmented line
if($this->getOption('grid_straight')) {
$grid_angles = [];
$points = array_merge($this->getGridPointsX(0), $this->getSubDivsX(0));
foreach($points as $point) {
$new_x = $point->position - $this->pad_left;
$grid_angles[] = $this->arad + $new_x / $this->radius;
}
// put the grid angles in order
sort($grid_angles);
$x1 = $this->xc + $y * sin($this->arad);
$y1 = $this->yc + $y * cos($this->arad);
$path->add('M', $x1, $y1, 'L');
foreach($grid_angles as $a) {
$x1 = $this->xc + $y * sin($a);
$y1 = $this->yc + $y * cos($a);
$path->add($x1, $y1);
}
$path->add('z');
} else {
$yc_num = new Number($this->yc);
$p1 = new Number($this->xc - $y);
$p2 = new Number($this->xc + $y);
$path->add('M', $p1, $yc_num, 'A', $y, $y, 0, 1, 1, $p2, $yc_num);
$path->add('M', $p2, $yc_num, 'A', $y, $y, 0, 1, 1, $p1, $yc_num);
}
// update values for text position
$x = $this->xc;
$y = $this->yc - $y;
$w = 0;
$h = 0;
}
return $path;
}
/**
* Remaps gradient to make it start at the right radius
*/
protected function remapGradient($g, $points)
{
$gl = new GradientList($this);
$d = $gl->decompose($g);
$p1 = $points[0]->position;
$p2 = $points[1]->position;
$offset = 100 * $p1 / $p2;
$mult = (100 - $offset) / 100;
$remapped = [ rtrim(implode(':', $d[0]), ':') ];
foreach($d as $stop) {
$stop[0] = $offset + $stop[0] * $mult;
$remapped[] = rtrim(implode(':', $stop), ':');
}
return new Colour($this, $remapped, true, true, true);
}
}
|