<?php
/**
* Copyright (C) 2013-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 BoxAndWhiskerGraph extends PointGraph {
private $min_value = null;
private $max_value = null;
public function __construct($w, $h, array $settings, array $fixed_settings = [])
{
$fs = [
'label_centre' => !isset($settings['datetime_keys']),
'require_structured' => ['top', 'bottom', 'wtop', 'wbottom'],
];
$fs = array_merge($fs, $fixed_settings);
parent::__construct($w, $h, $settings, $fs);
}
protected function draw()
{
$body = $this->grid() . $this->underShapes();
$bar_width = $this->barWidth();
$dataset = $this->getOption(['dataset', 0], 0);
$x_axis = $this->x_axes[$this->main_x_axis];
$bspace = max(0, ($this->x_axes[$this->main_x_axis]->unit() - $bar_width) / 2);
$whisker_width = $this->getOption('whisker_width');
$whisker_dash = $this->getOption('whisker_dash');
$median_width = $this->getOption('median_stroke_width');
$median_dash = $this->getOption('median_dash');
$median_colour = $this->getOption('median_colour');
$bnum = 0;
$series = '';
foreach($this->values[$dataset] as $item) {
$bar_pos = $this->gridPosition($item, $bnum);
if($item->value !== null && $bar_pos !== null) {
$box_style = ['fill' => $this->getColour($item, $bnum, $dataset)];
$this->setStroke($box_style, $item, $bnum, $dataset);
$this->setLegendEntry($dataset, $bnum, $item, $box_style);
$top = $item->top;
$bottom = $item->bottom;
$round = max($this->getItemOption('bar_round', $dataset, $item), 0);
$m_colour = null;
if(!empty($median_colour)) {
$cg = new ColourGroup($this, $item, $bnum, $dataset, 'median_colour');
$m_colour = $cg->stroke();
}
$shape = $this->whiskerBox($bspace + $bar_pos, $bar_width, $item->value,
$top, $bottom, $item->wtop, $item->wbottom, $round, $whisker_width,
$whisker_dash, $median_width, $median_dash, $m_colour);
// wrap the whisker box in a group
$g = [];
$show_label = $this->addDataLabel($dataset, $bnum, $g, $item,
$bspace + $bar_pos, $this->gridY($top), $bar_width,
$this->gridY($bottom) - $this->gridY($top));
if($this->getOption('show_tooltips'))
$this->setTooltip($g, $item, $dataset, $item->key, $item->value, $show_label);
if($this->getOption('show_context_menu'))
$this->setContextMenu($g, $dataset, $item, $show_label);
if($this->getOption('semantic_classes'))
$g['class'] = 'series0';
$group = $this->element('g', array_merge($g, $box_style), null, $shape);
$series .= $this->getLink($item, $item->key, $group);
// add outliers as markers
$x = $bar_pos + $x_axis->unit() / 2;
foreach($this->getOutliers($item) as $ovalue) {
$y = $this->gridY($ovalue);
$this->addMarker($x, $y, $item);
}
}
++$bnum;
}
$group = [];
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();
$body .= $this->axes();
$body .= $this->drawMarkers();
return $body;
}
/**
* Returns the width of a bar
*/
protected function barWidth()
{
$bw = $this->getOption('bar_width');
if(is_numeric($bw) && $bw >= 1)
return $bw;
$unit_w = $this->x_axes[$this->main_x_axis]->unit();
$bw = $unit_w - $this->getOption('bar_space');
return max(1, $bw, $this->getOption('bar_width_min'));
}
/**
* Returns the code for a box with whiskers
*/
protected function whiskerBox($x, $w, $median, $top, $bottom,
$wtop, $wbottom, $round, $whisker_width, $whisker_dash,
$median_width, $median_dash, Colour $median_colour = null)
{
$t = $this->gridY($top);
$b = $this->gridY($bottom);
$wt = $this->gridY($wtop);
$wb = $this->gridY($wbottom);
$box = ['x' => $x, 'y' => $t, 'width' => $w, 'height' => $b - $t];
if($round > 0) {
$box['rx'] = $box['ry'] = min($round, $box['width'] / 2,
$box['height'] / 2);
}
$rect = $this->element('rect', $box);
// whisker lines
$lg = $w * (1 - $whisker_width) * 0.5;
$ll = $x + $lg;
$lr = $x + $w - $lg;
$l = ['x1' => $ll, 'x2' => $lr];
$l['y1'] = $l['y2'] = $wt;
$l1 = $this->element('line', $l);
$l['y1'] = $l['y2'] = $wb;
$l2 = $this->element('line', $l);
// median line
$l['x1'] = $x;
$l['x2'] = $x + $w;
$l['y1'] = $l['y2'] = $this->gridY($median);
$style = [ 'stroke-width' => $median_width ];
if($median_colour !== null)
$style['stroke'] = $median_colour;
if(!empty($median_dash))
$style['stroke-dasharray'] = $median_dash;
$l3 = $this->element('line', array_merge($l, $style));
// whisker dashed lines
$style = [ 'stroke-dasharray' => $whisker_dash ];
$l['x1'] = $l['x2'] = $x + $w / 2;
$l['y1'] = $wt;
$l['y2'] = $t;
$w1 = $this->element('line', array_merge($l, $style));
$l['y1'] = $wb;
$l['y2'] = $b;
$w2 = $this->element('line', array_merge($l, $style));
return $rect . $w1 . $w2 . $l1 . $l2 . $l3;
}
/**
* Checks that the data contains sensible values
*/
protected function checkValues()
{
parent::checkValues();
foreach($this->values[0] as $item) {
$val = $item->value;
if($val === null)
continue;
$wb = $item->wbottom;
$wt = $item->wtop;
$b = $item->bottom;
$t = $item->top;
if($wb > $b || $wt < $t || $val < $b || $val > $t) {
$wb = new Number($wb);
$b = new Number($b);
$wt = new Number($wt);
$t = new Number($t);
$val = new Number($val);
throw new \Exception('Data problem: ' . $wb . '--[' . $b . ' ' . $val .
' ' . $t . ']--' . $wt);
}
}
}
/**
* Return box for legend
*/
public function drawLegendEntry($x, $y, $w, $h, $entry)
{
$box = ['x' => $x, 'y' => $y, 'width' => $w, 'height' => $h];
return $this->element('rect', $box, $entry->style);
}
/**
* Returns the maximum bar end
*/
public function getMaxValue()
{
if($this->max_value !== null)
return $this->max_value;
$max = null;
$dataset = $this->getOption(['dataset', 0], 0);
foreach($this->values[$dataset] as $item) {
if($item->value === null)
continue;
$points = [$item->wtop];
$points = array_merge($points, $this->getOutliers($item));
$m = max($points);
if($max === null || $m > $max)
$max = $m;
}
return ($this->max_value = $max);
}
/**
* Returns the minimum bar end
*/
public function getMinValue()
{
if($this->min_value !== null)
return $this->min_value;
$min = null;
$dataset = $this->getOption(['dataset', 0], 0);
foreach($this->values[$dataset] as $item) {
if($item->value === null)
continue;
$points = [$item->wbottom];
$points = array_merge($points, $this->getOutliers($item));
$m = min($points);
if($min === null || $m < $min)
$min = $m;
}
return ($this->min_value = $min);
}
/**
* Returns the list of outliers for an item
*/
protected function getOutliers(&$item)
{
$outliers = [];
$structure = $this->getOption('structure');
if(!isset($structure['outliers']) ||
!is_array($structure['outliers']))
return $outliers;
$min = $item->wbottom;
$max = $item->wtop;
foreach($structure['outliers'] as $o) {
$v = $item->rawData($o);
if($v !== null && ($v > $max || $v < $min))
$outliers[] = $v;
}
return $outliers;
}
}
|