| 1 |
<?php |
| 2 |
/** |
| 3 |
* Copyright (C) 2009-2022 Graham Breach |
| 4 |
* |
| 5 |
* This program is free software: you can redistribute it and/or modify |
| 6 |
* it under the terms of the GNU Lesser General Public License as published by |
| 7 |
* the Free Software Foundation, either version 3 of the License, or |
| 8 |
* (at your option) any later version. |
| 9 |
* |
| 10 |
* This program is distributed in the hope that it will be useful, |
| 11 |
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 13 |
* GNU Lesser General Public License for more details. |
| 14 |
* |
| 15 |
* You should have received a copy of the GNU Lesser General Public License |
| 16 |
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 17 |
*/ |
| 18 |
/** |
| 19 |
* For more information, please contact <graham@goat1000.com> |
| 20 |
*/ |
| 21 |
|
| 22 |
namespace Goat1000\SVGGraph; |
| 23 |
|
| 24 |
abstract class GridGraph extends Graph { |
| 25 |
|
| 26 |
protected $x_axes; |
| 27 |
protected $y_axes; |
| 28 |
protected $main_x_axis = 0; |
| 29 |
protected $main_y_axis = 0; |
| 30 |
protected $y_axis_positions = []; |
| 31 |
protected $dataset_axes = null; |
| 32 |
|
| 33 |
protected $g_width = null; |
| 34 |
protected $g_height = null; |
| 35 |
protected $label_adjust_done = false; |
| 36 |
protected $axes_calc_done = false; |
| 37 |
protected $grid_calc_done = false; |
| 38 |
protected $guidelines; |
| 39 |
protected $min_guide = ['x' => null, 'y' => null]; |
| 40 |
protected $max_guide = ['x' => null, 'y' => null]; |
| 41 |
|
| 42 |
protected $grid_limit; |
| 43 |
private $grid_clip_id = []; |
| 44 |
|
| 45 |
public function __construct($w, $h, array $settings, array $fixed_settings = []) |
| 46 |
{ |
| 47 |
$fs = [ |
| 48 |
// Set to true for block-based labelling |
| 49 |
'label_centre' => false, |
| 50 |
// Set to true for graphs that don't support multiple axes (e.g. stacked) |
| 51 |
'single_axis' => false, |
| 52 |
]; |
| 53 |
$fs = array_merge($fs, $fixed_settings); |
| 54 |
|
| 55 |
// deprecated options need converting |
| 56 |
if(isset($settings['show_label_h']) && |
| 57 |
!isset($settings['show_axis_text_h'])) |
| 58 |
$settings['show_axis_text_h'] = $settings['show_label_h']; |
| 59 |
if(isset($settings['show_label_v']) && |
| 60 |
!isset($settings['show_axis_text_v'])) |
| 61 |
$settings['show_axis_text_v'] = $settings['show_label_v']; |
| 62 |
|
| 63 |
// convert _x and _y labels to _h and _v |
| 64 |
if(!empty($settings['label_y']) && empty($settings['label_v'])) |
| 65 |
$settings['label_v'] = $settings['label_y']; |
| 66 |
if(!empty($settings['label_x']) && empty($settings['label_h'])) |
| 67 |
$settings['label_h'] = $settings['label_x']; |
| 68 |
|
| 69 |
parent::__construct($w, $h, $settings, $fs); |
| 70 |
|
| 71 |
// set up user text classes file |
| 72 |
$tcf = $this->getOption('text_classes_file'); |
| 73 |
if(!empty($tcf)) |
| 74 |
TextClass::setFile($tcf); |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Modifies the graph padding to allow room for labels |
| 79 |
*/ |
| 80 |
protected function labelAdjustment() |
| 81 |
{ |
| 82 |
$grid_l = $grid_t = $grid_r = $grid_b = null; |
| 83 |
|
| 84 |
$grid_set = $this->getOption('grid_left', 'grid_right', 'grid_top', |
| 85 |
'grid_bottom'); |
| 86 |
if($grid_set) { |
| 87 |
if(!empty($this->getOption('grid_left'))) |
| 88 |
$grid_l = $this->pad_left = abs($this->getOption('grid_left')); |
| 89 |
if(!empty($this->getOption('grid_top'))) |
| 90 |
$grid_t = $this->pad_top = abs($this->getOption('grid_top')); |
| 91 |
|
| 92 |
if(!empty($this->getOption('grid_bottom'))) { |
| 93 |
$gb = $this->getOption('grid_bottom'); |
| 94 |
$grid_b = $this->pad_bottom = $gb < 0 ? abs($gb) : $this->height - $gb; |
| 95 |
} |
| 96 |
if(!empty($this->getOption('grid_right'))) { |
| 97 |
$gr = $this->getOption('grid_right'); |
| 98 |
$grid_r = $this->pad_right = $gr < 0 ? abs($gr) : $this->width - $gr; |
| 99 |
} |
| 100 |
} |
| 101 |
|
| 102 |
$label_v = $this->getOption('label_v'); |
| 103 |
$label_h = $this->getOption('label_h'); |
| 104 |
if($this->getOption('axis_right') && !empty($label_v) && |
| 105 |
$this->yAxisCount() <= 1) { |
| 106 |
$label = is_array($label_v) ? $label_v[0] : $label_v; |
| 107 |
$this->setOption('label_v', [0 => '', 1 => $label]); |
| 108 |
} |
| 109 |
if($this->getOption('axis_top') && !empty($label_h) && |
| 110 |
$this->xAxisCount() <= 1) { |
| 111 |
$label = is_array($label_h) ? $label_h[0] : $label_h; |
| 112 |
$this->setOption('label_h', [0 => '', 1 => $label]); |
| 113 |
} |
| 114 |
|
| 115 |
$pad_l = $pad_r = $pad_b = $pad_t = 0; |
| 116 |
$space_x = $this->width - $this->pad_left - $this->pad_right; |
| 117 |
$space_y = $this->height - $this->pad_top - $this->pad_bottom; |
| 118 |
$ends = $this->getAxisEnds(); |
| 119 |
$extra_r = $extra_t = 0; |
| 120 |
|
| 121 |
for($i = 0; $i < 10; ++$i) { |
| 122 |
// find the text bounding box and add overlap to padding |
| 123 |
// repeat with the new measurements in case overlap increases |
| 124 |
$x_len = $space_x - $pad_r - $pad_l; |
| 125 |
$y_len = $space_y - $pad_t - $pad_b; |
| 126 |
|
| 127 |
// 3D graphs will use this to reduce axis length |
| 128 |
list($extra_r, $extra_t) = $this->adjustAxes($x_len, $y_len); |
| 129 |
list($x_axes, $y_axes) = $this->getAxes($ends, $x_len, $y_len); |
| 130 |
$bbox = $this->findAxisBBox($x_len, $y_len, $x_axes, $y_axes); |
| 131 |
$pr = $pl = $pb = $pt = 0; |
| 132 |
|
| 133 |
if($bbox['max_x'] > $x_len) |
| 134 |
$pr = ceil($bbox['max_x'] - $x_len); |
| 135 |
if($bbox['min_x'] < 0) |
| 136 |
$pl = ceil(abs($bbox['min_x'])); |
| 137 |
if($bbox['min_y'] < 0) |
| 138 |
$pt = ceil(abs($bbox['min_y'])); |
| 139 |
if($bbox['max_y'] > $y_len) |
| 140 |
$pb = ceil($bbox['max_y'] - $y_len); |
| 141 |
|
| 142 |
if($pr == $pad_r && $pl == $pad_l && $pt == $pad_t && $pb == $pad_b) |
| 143 |
break; |
| 144 |
$pad_r = $pr; |
| 145 |
$pad_l = $pl; |
| 146 |
$pad_t = $pt; |
| 147 |
$pad_b = $pb; |
| 148 |
} |
| 149 |
|
| 150 |
$pad_r += $extra_r; |
| 151 |
$pad_t += $extra_t; |
| 152 |
|
| 153 |
// apply the extra padding |
| 154 |
if($grid_l === null) |
| 155 |
$this->pad_left += $pad_l; |
| 156 |
if($grid_b === null) |
| 157 |
$this->pad_bottom += $pad_b; |
| 158 |
if($grid_r === null) |
| 159 |
$this->pad_right += $pad_r; |
| 160 |
if($grid_t === null) |
| 161 |
$this->pad_top += $pad_t; |
| 162 |
|
| 163 |
if($grid_r !== null || $grid_l !== null) { |
| 164 |
// fixed grid position means recalculating axis positions |
| 165 |
$offset = 0; |
| 166 |
foreach($this->y_axis_positions as $axis_no => $pos) { |
| 167 |
if($axis_no == 0) |
| 168 |
continue; |
| 169 |
if($offset) { |
| 170 |
$newpos = $pos + $offset; |
| 171 |
} else { |
| 172 |
$newpos = $this->width - $this->pad_left - $this->pad_right; |
| 173 |
$offset = $newpos - $pos; |
| 174 |
} |
| 175 |
$this->y_axis_positions[$axis_no] = $newpos; |
| 176 |
} |
| 177 |
} |
| 178 |
|
| 179 |
// see if the axes fit |
| 180 |
foreach($this->y_axis_positions as $axis_no => $pos) { |
| 181 |
if($axis_no > 0 && $pos <= 0) |
| 182 |
throw new \Exception('Not enough space for ' . $this->yAxisCount() . ' axes'); |
| 183 |
} |
| 184 |
$this->label_adjust_done = true; |
| 185 |
} |
| 186 |
|
| 187 |
/** |
| 188 |
* Subclasses can override this to modify axis lengths |
| 189 |
* Return amount of padding added [r,t] |
| 190 |
*/ |
| 191 |
protected function adjustAxes(&$x_len, &$y_len) |
| 192 |
{ |
| 193 |
return [0, 0]; |
| 194 |
} |
| 195 |
|
| 196 |
/** |
| 197 |
* Find the bounding box of the axis text for given axis lengths |
| 198 |
*/ |
| 199 |
protected function findAxisBBox($length_x, $length_y, $x_axes, $y_axes) |
| 200 |
{ |
| 201 |
// initialise maxima and minima |
| 202 |
$min_x = [$this->width]; |
| 203 |
$min_y = [$this->height]; |
| 204 |
$max_x = [0]; |
| 205 |
$max_y = [0]; |
| 206 |
|
| 207 |
$axis_no = -1; |
| 208 |
foreach($x_axes as $x_axis) { |
| 209 |
++$axis_no; |
| 210 |
if($x_axis === null) |
| 211 |
continue; |
| 212 |
$display_axis = $this->getDisplayAxis($x_axis, $axis_no, 'h', 'x'); |
| 213 |
$m = $display_axis->measure(); |
| 214 |
$offset = $axis_no ? 0 : $length_y; |
| 215 |
$min_x[] = $m->x1; |
| 216 |
$min_y[] = $m->y1 + $offset; |
| 217 |
$max_x[] = $m->x2; |
| 218 |
$max_y[] = $m->y2 + $offset; |
| 219 |
} |
| 220 |
|
| 221 |
$axis_no = -1; |
| 222 |
$right_pos = $length_x; |
| 223 |
foreach($y_axes as $y_axis) { |
| 224 |
++$axis_no; |
| 225 |
if($y_axis === null) |
| 226 |
continue; |
| 227 |
$ybb = $this->yAxisBBox($y_axis, $length_y, $axis_no); |
| 228 |
|
| 229 |
if($axis_no > 0) { |
| 230 |
// for offset axes, the inside overlap must be added on too |
| 231 |
$outer = $ybb['max_x']; |
| 232 |
$inner = $axis_no > 1 ? abs($ybb['min_x']) : 0; |
| 233 |
|
| 234 |
$this->y_axis_positions[$axis_no] = $right_pos + $inner; |
| 235 |
$ybb['max_x'] += $right_pos + $inner; |
| 236 |
$ybb['min_x'] += $right_pos + $inner; |
| 237 |
$right_pos += $inner + $outer + $this->getOption('axis_space'); |
| 238 |
} else { |
| 239 |
$this->y_axis_positions[$axis_no] = 0; |
| 240 |
} |
| 241 |
|
| 242 |
$max_x[] = $ybb['max_x']; |
| 243 |
$min_x[] = $ybb['min_x']; |
| 244 |
$max_y[] = $ybb['max_y']; |
| 245 |
$min_y[] = $ybb['min_y']; |
| 246 |
} |
| 247 |
return [ |
| 248 |
'min_x' => min($min_x), 'min_y' => min($min_y), |
| 249 |
'max_x' => max($max_x), 'max_y' => max($max_y) |
| 250 |
]; |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* Returns bounding box for a Y-axis |
| 255 |
*/ |
| 256 |
protected function yAxisBBox($axis, $length, $axis_no) |
| 257 |
{ |
| 258 |
$display_axis = $this->getDisplayAxis($axis, $axis_no, 'v', 'y'); |
| 259 |
$bbox = $display_axis->measure(); |
| 260 |
|
| 261 |
// reversed Y-axis measures from bottom |
| 262 |
if($axis->reversed()) |
| 263 |
$bbox->offset(0, $length); |
| 264 |
return [ 'min_x' => $bbox->x1, 'min_y' => $bbox->y1, |
| 265 |
'max_x' => $bbox->x2, 'max_y' => $bbox->y2 ]; |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* Sets up grid width and height to fill padded area |
| 270 |
*/ |
| 271 |
protected function setGridDimensions() |
| 272 |
{ |
| 273 |
$this->g_height = $this->height - $this->pad_top - $this->pad_bottom; |
| 274 |
$this->g_width = $this->width - $this->pad_left - $this->pad_right; |
| 275 |
} |
| 276 |
|
| 277 |
/** |
| 278 |
* Returns the list of datasets and their axis numbers |
| 279 |
*/ |
| 280 |
public function getDatasetAxes() |
| 281 |
{ |
| 282 |
if($this->dataset_axes !== null) |
| 283 |
return $this->dataset_axes; |
| 284 |
|
| 285 |
$dataset_axis = $this->getOption('dataset_axis'); |
| 286 |
$default_axis = $this->getOption('axis_right') ? 1 : 0; |
| 287 |
$single_axis = $this->getOption('single_axis'); |
| 288 |
if(empty($this->multi_graph)) { |
| 289 |
$enabled_datasets = [0]; |
| 290 |
$v =& $this->values; |
| 291 |
} else { |
| 292 |
$enabled_datasets = $this->multi_graph->getEnabledDatasets(); |
| 293 |
$v =& $this->multi_graph->getValues(); |
| 294 |
} |
| 295 |
|
| 296 |
$d_axes = []; |
| 297 |
foreach($enabled_datasets as $d) { |
| 298 |
$axis = $default_axis; |
| 299 |
|
| 300 |
// only use the chosen dataset axis when allowed and not empty |
| 301 |
if(!$single_axis && isset($dataset_axis[$d]) && $v->itemsCount($d) > 0) |
| 302 |
$axis = $dataset_axis[$d]; |
| 303 |
$d_axes[$d] = $axis; |
| 304 |
} |
| 305 |
|
| 306 |
// check that the axes are used in order |
| 307 |
$used = []; |
| 308 |
foreach($d_axes as $a) |
| 309 |
$used[$a] = 1; |
| 310 |
$max = max($d_axes); |
| 311 |
$unused = []; |
| 312 |
for($a = $default_axis; $a <= $max; ++$a) |
| 313 |
if(!isset($used[$a])) |
| 314 |
$unused[] = $a; |
| 315 |
|
| 316 |
if(count($unused)) |
| 317 |
throw new \Exception('Unused axis: ' . implode(', ', $unused)); |
| 318 |
|
| 319 |
$this->dataset_axes = $d_axes; |
| 320 |
return $this->dataset_axes; |
| 321 |
} |
| 322 |
|
| 323 |
/** |
| 324 |
* Returns the number of Y-axes |
| 325 |
*/ |
| 326 |
protected function yAxisCount() |
| 327 |
{ |
| 328 |
$dataset_axes = $this->getDatasetAxes(); |
| 329 |
if(count($dataset_axes) <= 1) |
| 330 |
return 1; |
| 331 |
|
| 332 |
return count(array_unique($dataset_axes)); |
| 333 |
} |
| 334 |
|
| 335 |
/** |
| 336 |
* Returns the number of X-axes |
| 337 |
*/ |
| 338 |
protected function xAxisCount() |
| 339 |
{ |
| 340 |
return 1; |
| 341 |
} |
| 342 |
|
| 343 |
/** |
| 344 |
* Returns an x or y axis, or NULL if it does not exist |
| 345 |
*/ |
| 346 |
public function getAxis($axis, $num) |
| 347 |
{ |
| 348 |
if($num === null) |
| 349 |
$num = ($axis == 'y' ? $this->main_y_axis : $this->main_x_axis); |
| 350 |
$axis_var = $axis == 'y' ? 'y_axes' : 'x_axes'; |
| 351 |
if(isset($this->{$axis_var}) && isset($this->{$axis_var}[$num])) |
| 352 |
return $this->{$axis_var}[$num]; |
| 353 |
return null; |
| 354 |
} |
| 355 |
|
| 356 |
/** |
| 357 |
* Returns the Y-axis for a dataset |
| 358 |
*/ |
| 359 |
protected function datasetYAxis($dataset) |
| 360 |
{ |
| 361 |
$dataset_axes = $this->getDatasetAxes(); |
| 362 |
if(isset($dataset_axes[$dataset])) |
| 363 |
return $dataset_axes[$dataset]; |
| 364 |
return $this->getOption('axis_right') ? 1 : 0; |
| 365 |
} |
| 366 |
|
| 367 |
/** |
| 368 |
* Returns the minimum value for an axis |
| 369 |
*/ |
| 370 |
protected function getAxisMinValue($axis) |
| 371 |
{ |
| 372 |
if($this->yAxisCount() <= 1) |
| 373 |
return $this->getMinValue(); |
| 374 |
|
| 375 |
$min = []; |
| 376 |
$dataset_axes = $this->getDatasetAxes(); |
| 377 |
foreach($dataset_axes as $dataset => $d_axis) { |
| 378 |
if($d_axis == $axis) { |
| 379 |
$min_val = $this->values->getMinValue($dataset); |
| 380 |
if($min_val !== null) |
| 381 |
$min[] = $min_val; |
| 382 |
} |
| 383 |
} |
| 384 |
return empty($min) ? null : min($min); |
| 385 |
} |
| 386 |
|
| 387 |
/** |
| 388 |
* Returns the maximum value for an axis |
| 389 |
*/ |
| 390 |
protected function getAxisMaxValue($axis) |
| 391 |
{ |
| 392 |
if($this->yAxisCount() <= 1) |
| 393 |
return $this->getMaxValue(); |
| 394 |
|
| 395 |
$max = []; |
| 396 |
$dataset_axes = $this->getDatasetAxes(); |
| 397 |
foreach($dataset_axes as $dataset => $d_axis) { |
| 398 |
if($d_axis == $axis) { |
| 399 |
$max_val = $this->values->getMaxValue($dataset); |
| 400 |
if($max_val !== null) |
| 401 |
$max[] = $max_val; |
| 402 |
} |
| 403 |
} |
| 404 |
return empty($max) ? null : max($max); |
| 405 |
} |
| 406 |
|
| 407 |
/** |
| 408 |
* Returns fixed min and max option for an axis |
| 409 |
*/ |
| 410 |
protected function getFixedAxisOptions($axis, $index) |
| 411 |
{ |
| 412 |
$a = $axis == 'y' ? 'v' : 'h'; |
| 413 |
$min = $this->getOption(['axis_min_' . $a, $index]); |
| 414 |
$max = $this->getOption(['axis_max_' . $a, $index]); |
| 415 |
return [$min, $max]; |
| 416 |
} |
| 417 |
|
| 418 |
/** |
| 419 |
* Returns an array containing the value and key axis min and max |
| 420 |
*/ |
| 421 |
protected function getAxisEnds() |
| 422 |
{ |
| 423 |
// check guides |
| 424 |
if($this->guidelines === null) |
| 425 |
$this->calcGuidelines(); |
| 426 |
|
| 427 |
$v_max = $v_min = $k_max = $k_min = []; |
| 428 |
$g_min_x = $g_min_y = $g_max_x = $g_max_y = null; |
| 429 |
|
| 430 |
if($this->guidelines !== null) { |
| 431 |
list($g_min_x, $g_min_y, $g_max_x, $g_max_y) = $this->guidelines->getMinMax(); |
| 432 |
} |
| 433 |
$y_axis_count = $this->yAxisCount(); |
| 434 |
$x_axis_count = $this->xAxisCount(); |
| 435 |
|
| 436 |
for($i = 0; $i < $y_axis_count; ++$i) { |
| 437 |
list($fixed_min, $fixed_max) = $this->getFixedAxisOptions('y', $i); |
| 438 |
|
| 439 |
// validate |
| 440 |
if(is_numeric($fixed_min) && is_numeric($fixed_max) && |
| 441 |
$fixed_max < $fixed_min) |
| 442 |
throw new \Exception('Invalid Y axis options: min > max (' . |
| 443 |
$fixed_min . ' > ' . $fixed_max . ')'); |
| 444 |
|
| 445 |
if(is_numeric($fixed_min) && is_numeric($fixed_max)) { |
| 446 |
$v_min[] = $fixed_min; |
| 447 |
$v_max[] = $fixed_max; |
| 448 |
} else { |
| 449 |
$allow_zero = !$this->getOption(['log_axis_y', $i], false); |
| 450 |
$prefer_zero = $this->getOption(['axis_zero_y', $i], true); |
| 451 |
$axis_min_value = $this->getAxisMinValue($i); |
| 452 |
$axis_max_value = $this->getAxisMaxValue($i); |
| 453 |
if($g_min_y !== null && $g_min_y < $axis_min_value) |
| 454 |
$axis_min_value = $g_min_y; |
| 455 |
if($g_max_y !== null && $g_max_y > $axis_max_value) |
| 456 |
$axis_max_value = $g_max_y; |
| 457 |
|
| 458 |
$v_min[] = is_numeric($fixed_min) ? $fixed_min : |
| 459 |
Axis::calcMinimum($axis_min_value, $axis_max_value, |
| 460 |
$allow_zero, $prefer_zero); |
| 461 |
|
| 462 |
$v_max[] = is_numeric($fixed_max) ? $fixed_max : |
| 463 |
Axis::calcMaximum($axis_min_value, $axis_max_value, |
| 464 |
$allow_zero, $prefer_zero); |
| 465 |
} |
| 466 |
if($v_max[$i] < $v_min[$i]) |
| 467 |
throw new \Exception('Invalid Y axis: min > max (' . |
| 468 |
$v_min[$i] . ' > ' . $v_max[$i] . ')'); |
| 469 |
} |
| 470 |
|
| 471 |
for($i = 0; $i < $x_axis_count; ++$i) { |
| 472 |
list($fixed_min, $fixed_max) = $this->getFixedAxisOptions('x', $i); |
| 473 |
|
| 474 |
if($this->getOption('datetime_keys')) { |
| 475 |
// 0 is 1970-01-01, not a useful minimum |
| 476 |
if(empty($fixed_max)) { |
| 477 |
// guidelines support datetime values too |
| 478 |
if($g_max_x !== null) |
| 479 |
$k_max[] = max($this->getMaxKey(), $g_max_x); |
| 480 |
else |
| 481 |
$k_max[] = $this->getMaxKey(); |
| 482 |
} else { |
| 483 |
$d = Graph::dateConvert($fixed_max); |
| 484 |
// subtract a sec |
| 485 |
if($d !== null) |
| 486 |
$k_max[] = $d - 1; |
| 487 |
else |
| 488 |
throw new \Exception('Could not convert [' . $fixed_max . |
| 489 |
'] to datetime'); |
| 490 |
} |
| 491 |
if(empty($fixed_min)) { |
| 492 |
if($g_min_x !== null) |
| 493 |
$k_min[] = min($this->getMinKey(), $g_min_x); |
| 494 |
else |
| 495 |
$k_min[] = $this->getMinKey(); |
| 496 |
} else { |
| 497 |
$d = Graph::dateConvert($fixed_min); |
| 498 |
if($d !== null) |
| 499 |
$k_min[] = $d; |
| 500 |
else |
| 501 |
throw new \Exception('Could not convert [' . $fixed_min . |
| 502 |
'] to datetime'); |
| 503 |
} |
| 504 |
} else { |
| 505 |
// validate |
| 506 |
if(is_numeric($fixed_min) && is_numeric($fixed_max) && |
| 507 |
$fixed_max < $fixed_min) |
| 508 |
throw new \Exception('Invalid X axis options: min > max (' . |
| 509 |
$fixed_min . ' > ' . $fixed_max . ')'); |
| 510 |
|
| 511 |
$log_axis = $this->getOption(['log_axis_x', $i]); |
| 512 |
if(is_numeric($fixed_max)) { |
| 513 |
$k_max[] = $fixed_max; |
| 514 |
} elseif($log_axis) { |
| 515 |
$max_val = $this->getMaxKey(); |
| 516 |
if($g_max_x !== null) |
| 517 |
$max_val = max($max_val, (float)$g_max_x); |
| 518 |
$k_max[] = $max_val; |
| 519 |
} else { |
| 520 |
$k_max[] = max(0, $this->getMaxKey(), (float)$g_max_x); |
| 521 |
} |
| 522 |
|
| 523 |
if(is_numeric($fixed_min)) { |
| 524 |
$k_min[] = $fixed_min; |
| 525 |
} elseif($log_axis) { |
| 526 |
$min_val = $this->getMinKey(); |
| 527 |
if($g_min_x !== null) |
| 528 |
$min_val = min($min_val, (float)$g_min_x); |
| 529 |
$k_min[] = $min_val; |
| 530 |
} else { |
| 531 |
$k_min[] = min(0, $this->getMinKey(), (float)$g_min_x); |
| 532 |
} |
| 533 |
} |
| 534 |
if($k_max[$i] < $k_min[$i]) |
| 535 |
throw new \Exception('Invalid X axis: min > max (' . $k_min[$i] . |
| 536 |
' > ' . $k_max[$i] . ')'); |
| 537 |
} |
| 538 |
return compact('v_max', 'v_min', 'k_max', 'k_min'); |
| 539 |
} |
| 540 |
|
| 541 |
/** |
| 542 |
* Returns the factory for an X-axis |
| 543 |
*/ |
| 544 |
protected function getXAxisFactory() |
| 545 |
{ |
| 546 |
$x_bar = $this->getOption('label_centre'); |
| 547 |
return new AxisFactory($this->getOption('datetime_keys'), $this->settings, |
| 548 |
true, $x_bar, false); |
| 549 |
} |
| 550 |
|
| 551 |
/** |
| 552 |
* Returns the factory for a Y-axis |
| 553 |
*/ |
| 554 |
protected function getYAxisFactory() |
| 555 |
{ |
| 556 |
return new AxisFactory(false, $this->settings, false, false, true); |
| 557 |
} |
| 558 |
|
| 559 |
protected function createXAxis($factory, $length, $ends, $i, $min_space, $grid_division) |
| 560 |
{ |
| 561 |
$max_h = $ends['k_max'][$i]; |
| 562 |
$min_h = $ends['k_min'][$i]; |
| 563 |
if(!is_numeric($max_h) || !is_numeric($min_h)) |
| 564 |
throw new \Exception('Non-numeric min/max'); |
| 565 |
|
| 566 |
$min_unit = 1; |
| 567 |
$units_after = (string)$this->getOption(['units_x', $i]); |
| 568 |
$units_before = (string)$this->getOption(['units_before_x', $i]); |
| 569 |
$decimal_digits = $this->getOption(['decimal_digits_x', $i], |
| 570 |
'decimal_digits'); |
| 571 |
$text_callback = $this->getOption(['axis_text_callback_x', $i], |
| 572 |
'axis_text_callback'); |
| 573 |
$values = $this->multi_graph ? $this->multi_graph : $this->values; |
| 574 |
$log = $this->getOption(['log_axis_x', $i]); |
| 575 |
$log_base = $this->getOption(['log_axis_x_base', $i]); |
| 576 |
$levels = $this->getOption(['axis_levels_h', $i]); |
| 577 |
$ticks = $this->getOption('axis_ticks_x'); |
| 578 |
|
| 579 |
return $factory->get($length, $min_h, $max_h, $min_unit, |
| 580 |
$min_space, $grid_division, $units_before, $units_after, |
| 581 |
$decimal_digits, $text_callback, $values, $log, $log_base, |
| 582 |
$levels, $ticks); |
| 583 |
} |
| 584 |
|
| 585 |
protected function createYAxis($factory, $length, $ends, $i, $min_space, $grid_division) |
| 586 |
{ |
| 587 |
$max_v = $ends['v_max'][$i]; |
| 588 |
$min_v = $ends['v_min'][$i]; |
| 589 |
if(!is_numeric($max_v) || !is_numeric($min_v)) |
| 590 |
throw new \Exception('Non-numeric min/max'); |
| 591 |
|
| 592 |
$min_unit = $this->getOption(['minimum_units_y', $i]); |
| 593 |
$text_callback = $this->getOption(['axis_text_callback_y', $i], |
| 594 |
'axis_text_callback'); |
| 595 |
$decimal_digits = $this->getOption(['decimal_digits_y', $i], |
| 596 |
'decimal_digits'); |
| 597 |
$units_after = (string)$this->getOption(['units_y', $i]); |
| 598 |
$units_before = (string)$this->getOption(['units_before_y', $i]); |
| 599 |
$log = $this->getOption(['log_axis_y', $i]); |
| 600 |
$log_base = $this->getOption(['log_axis_y_base', $i]); |
| 601 |
$ticks = $this->getOption(['axis_ticks_y', $i]); |
| 602 |
$values = $levels = null; |
| 603 |
|
| 604 |
if($min_v == $max_v) { |
| 605 |
if($min_unit > 0) { |
| 606 |
$inc = $min_unit; |
| 607 |
} else { |
| 608 |
$fallback = $this->getOption('axis_fallback_max'); |
| 609 |
$inc = $fallback > 0 ? $fallback : 1; |
| 610 |
} |
| 611 |
$max_v += $inc; |
| 612 |
} |
| 613 |
|
| 614 |
$y_axis = $factory->get($length, $min_v, $max_v, $min_unit, |
| 615 |
$min_space, $grid_division, $units_before, $units_after, |
| 616 |
$decimal_digits, $text_callback, $values, $log, $log_base, |
| 617 |
$levels, $ticks); |
| 618 |
$y_axis->setTightness($this->getOption(['axis_tightness_y', $i])); |
| 619 |
return $y_axis; |
| 620 |
} |
| 621 |
|
| 622 |
/** |
| 623 |
* Returns the X and Y axis class instances as a list |
| 624 |
*/ |
| 625 |
protected function getAxes($ends, &$x_len, &$y_len) |
| 626 |
{ |
| 627 |
$this->validateAxisOptions(); |
| 628 |
$x_axis_factory = $this->getXAxisFactory(); |
| 629 |
$y_axis_factory = $this->getYAxisFactory(); |
| 630 |
|
| 631 |
// at the moment there will only be 1 X axis, but allow for expansion |
| 632 |
$x_axes = []; |
| 633 |
$x_axis_count = $this->xAxisCount(); |
| 634 |
for($i = 0; $i < $x_axis_count; ++$i) { |
| 635 |
|
| 636 |
$min_space = $this->getOption(['minimum_grid_spacing_h', $i], |
| 637 |
'minimum_grid_spacing'); |
| 638 |
$grid_division = $this->getOption(['grid_division_h', $i]); |
| 639 |
if(is_numeric($grid_division)) { |
| 640 |
if($grid_division <= 0) |
| 641 |
throw new \Exception('Invalid grid division'); |
| 642 |
// if fixed grid spacing is specified, make the min spacing 1 pixel |
| 643 |
$min_space = 1; |
| 644 |
$this->setOption('minimum_grid_spacing_h', 1); |
| 645 |
} |
| 646 |
|
| 647 |
$x_axes[] = $this->createXAxis($x_axis_factory, $x_len, $ends, $i, $min_space, $grid_division); |
| 648 |
} |
| 649 |
// double X axis adds a second axis with same ends as first |
| 650 |
if($x_axis_count == 1 && $this->getOption('axis_double_x')) |
| 651 |
$x_axes[] = $this->createXAxis($x_axis_factory, $x_len, $ends, 0, $min_space, $grid_division); |
| 652 |
|
| 653 |
$y_axes = []; |
| 654 |
$y_axis_count = $this->yAxisCount(); |
| 655 |
for($i = 0; $i < $y_axis_count; ++$i) { |
| 656 |
|
| 657 |
$min_space = $this->getOption(['minimum_grid_spacing_v', $i], |
| 658 |
'minimum_grid_spacing'); |
| 659 |
// make sure minimum_grid_spacing option is an array |
| 660 |
if(!is_array($this->getOption('minimum_grid_spacing_v'))) |
| 661 |
$this->setOption('minimum_grid_spacing_v', []); |
| 662 |
|
| 663 |
$grid_division = $this->getOption(['grid_division_v', $i]); |
| 664 |
if(is_numeric($grid_division)) { |
| 665 |
if($grid_division <= 0) |
| 666 |
throw new \Exception('Invalid grid division'); |
| 667 |
// if fixed grid spacing is specified, make the min spacing 1 pixel |
| 668 |
$this->setOption('minimum_grid_spacing_v', 1, $i); |
| 669 |
$min_space = 1; |
| 670 |
} else { |
| 671 |
$mgsv = $this->getOption('minimum_grid_spacing_v'); |
| 672 |
if(!isset($mgsv[$i])) |
| 673 |
$this->setOption('minimum_grid_spacing_v', $min_space, $i); |
| 674 |
} |
| 675 |
|
| 676 |
$y_axes[] = $this->createYAxis($y_axis_factory, $y_len, $ends, $i, $min_space, $grid_division); |
| 677 |
} |
| 678 |
// double Y axis adds a second axis with same ends as first |
| 679 |
if($y_axis_count == 1 && $this->getOption('axis_double_y')) |
| 680 |
$y_axes[] = $this->createYAxis($y_axis_factory, $y_len, $ends, 0, $min_space, $grid_division); |
| 681 |
|
| 682 |
// set the main axis correctly |
| 683 |
if($this->getOption('axis_right') && count($y_axes) == 1) { |
| 684 |
$this->main_y_axis = 1; |
| 685 |
array_unshift($y_axes, null); |
| 686 |
} |
| 687 |
if($this->getOption('axis_top') && count($x_axes) == 1) { |
| 688 |
$this->main_x_axis = 1; |
| 689 |
array_unshift($x_axes, null); |
| 690 |
} |
| 691 |
return [$x_axes, $y_axes]; |
| 692 |
} |
| 693 |
|
| 694 |
/** |
| 695 |
* Axis options can be complicated |
| 696 |
*/ |
| 697 |
protected function validateAxisOptions() |
| 698 |
{ |
| 699 |
// disable units for associative keys |
| 700 |
if($this->values->associativeKeys()) { |
| 701 |
$this->setOption('units_x', null); |
| 702 |
$this->setOption('units_before_x', null); |
| 703 |
} |
| 704 |
|
| 705 |
// ticks are a bit tricky, could be an array or array of arrays or ... |
| 706 |
$ticks = $this->getOption('axis_ticks_y'); |
| 707 |
if(is_array($ticks)) { |
| 708 |
$count = count($ticks); |
| 709 |
$nulls = $arrays = 0; |
| 710 |
foreach($ticks as $t) { |
| 711 |
if(is_array($t)) |
| 712 |
++$arrays; |
| 713 |
elseif($t === null) |
| 714 |
++$nulls; |
| 715 |
} |
| 716 |
|
| 717 |
// if array of nulls, null it |
| 718 |
if($nulls == $count) |
| 719 |
$this->setOption('axis_ticks_y', null); |
| 720 |
// if single array, enclose it |
| 721 |
elseif($arrays == 0) |
| 722 |
$this->setOption('axis_ticks_y', [$ticks]); |
| 723 |
} |
| 724 |
} |
| 725 |
|
| 726 |
/** |
| 727 |
* Calculates the effect of axes, applying to padding |
| 728 |
*/ |
| 729 |
protected function calcAxes() |
| 730 |
{ |
| 731 |
if($this->axes_calc_done) |
| 732 |
return; |
| 733 |
|
| 734 |
// can't have multiple invisible axes |
| 735 |
if(!$this->getOption('show_axes')) |
| 736 |
$this->setOption('dataset_axis', null); |
| 737 |
|
| 738 |
$ends = $this->getAxisEnds(); |
| 739 |
if(!$this->label_adjust_done) |
| 740 |
$this->labelAdjustment(); |
| 741 |
if($this->g_height === null || $this->g_width === null) |
| 742 |
$this->setGridDimensions(); |
| 743 |
|
| 744 |
list($x_axes, $y_axes) = $this->getAxes($ends, $this->g_width, |
| 745 |
$this->g_height); |
| 746 |
|
| 747 |
$this->x_axes = $x_axes; |
| 748 |
$this->y_axes = $y_axes; |
| 749 |
$this->axes_calc_done = true; |
| 750 |
} |
| 751 |
|
| 752 |
/** |
| 753 |
* Calculates the position of grid lines |
| 754 |
*/ |
| 755 |
protected function calcGrid() |
| 756 |
{ |
| 757 |
if($this->grid_calc_done) |
| 758 |
return; |
| 759 |
|
| 760 |
$y_axis = $this->y_axes[$this->main_y_axis]; |
| 761 |
$x_axis = $this->x_axes[$this->main_x_axis]; |
| 762 |
$y_axis->getGridPoints(null); |
| 763 |
$x_axis->getGridPoints(null); |
| 764 |
|
| 765 |
$this->grid_limit = 0.01 + $this->g_width; |
| 766 |
if($this->getOption('label_centre')) |
| 767 |
$this->grid_limit -= $x_axis->unit() / 2; |
| 768 |
$this->grid_calc_done = true; |
| 769 |
} |
| 770 |
|
| 771 |
/** |
| 772 |
* Returns the grid points for a Y-axis |
| 773 |
*/ |
| 774 |
protected function getGridPointsY($axis) |
| 775 |
{ |
| 776 |
$a = $this->y_axes[$axis]; |
| 777 |
$offset = $a->reversed() ? $this->height - $this->pad_bottom : $this->pad_top; |
| 778 |
return $a->getGridPoints($offset); |
| 779 |
} |
| 780 |
|
| 781 |
/** |
| 782 |
* Returns the grid points for an X-axis |
| 783 |
*/ |
| 784 |
protected function getGridPointsX($axis) |
| 785 |
{ |
| 786 |
$a = $this->x_axes[$axis]; |
| 787 |
$offset = $a->reversed() ? $this->width - $this->pad_right : $this->pad_left; |
| 788 |
return $a->getGridPoints($offset); |
| 789 |
} |
| 790 |
|
| 791 |
/** |
| 792 |
* Returns the subdivisions for a Y-axis |
| 793 |
*/ |
| 794 |
protected function getSubDivsY($axis) |
| 795 |
{ |
| 796 |
$a = $this->y_axes[$axis]; |
| 797 |
$offset = $a->reversed() ? $this->height - $this->pad_bottom : $this->pad_top; |
| 798 |
return $a->getGridSubdivisions($this->getOption('minimum_subdivision'), |
| 799 |
$this->getOption(['minimum_units_y', $axis]), $offset, |
| 800 |
$this->getOption(['subdivision_v', $axis])); |
| 801 |
} |
| 802 |
|
| 803 |
/** |
| 804 |
* Returns the subdivisions for an X-axis |
| 805 |
*/ |
| 806 |
protected function getSubDivsX($axis) |
| 807 |
{ |
| 808 |
$a = $this->x_axes[$axis]; |
| 809 |
$offset = $a->reversed() ? $this->width - $this->pad_right : $this->pad_left; |
| 810 |
return $a->getGridSubdivisions($this->getOption('minimum_subdivision'), 1, |
| 811 |
$offset, $this->getOption(['subdivision_h', $axis])); |
| 812 |
} |
| 813 |
|
| 814 |
/** |
| 815 |
* A function to return the DisplayAxis - subclasses should override |
| 816 |
* to return a different axis type |
| 817 |
*/ |
| 818 |
protected function getDisplayAxis($axis, $axis_no, $orientation, $type) |
| 819 |
{ |
| 820 |
$var = 'main_' . $type . '_axis'; |
| 821 |
$main = ($axis_no == $this->{$var}); |
| 822 |
$levels = $this->getOption(['axis_levels_' . $orientation, $axis_no]); |
| 823 |
$class = 'Goat1000\SVGGraph\DisplayAxis'; |
| 824 |
if(is_numeric($levels) && $levels > 1) |
| 825 |
$class = 'Goat1000\SVGGraph\DisplayAxisLevels'; |
| 826 |
|
| 827 |
return new $class($this, $axis, $axis_no, $orientation, $type, $main, |
| 828 |
$this->getOption('label_centre')); |
| 829 |
} |
| 830 |
|
| 831 |
/** |
| 832 |
* Returns the location of an axis |
| 833 |
*/ |
| 834 |
protected function getAxisLocation($orientation, $axis_no) |
| 835 |
{ |
| 836 |
$x = $this->pad_left; |
| 837 |
$y = $this->pad_top + $this->g_height; |
| 838 |
if($orientation == 'h') { |
| 839 |
if($axis_no == 1) { |
| 840 |
// top axis |
| 841 |
$y = $this->pad_top; |
| 842 |
} else { |
| 843 |
$y0 = $this->y_axes[$this->main_y_axis]->zero(); |
| 844 |
if($this->getOption('show_axis_h') && $y0 >= 0 && $y0 <= $this->g_height) |
| 845 |
$y -= $y0; |
| 846 |
} |
| 847 |
} else { |
| 848 |
if($axis_no == 0) { |
| 849 |
$x0 = $this->x_axes[$this->main_x_axis]->zero(); |
| 850 |
if($x0 >= 1 && $x0 < $this->g_width) |
| 851 |
$x += $x0; |
| 852 |
} else { |
| 853 |
$x += $this->y_axis_positions[$axis_no]; |
| 854 |
} |
| 855 |
// Y axis is normally reversed |
| 856 |
if(!$this->y_axes[$axis_no]->reversed()) |
| 857 |
$y = $this->pad_top; |
| 858 |
} |
| 859 |
return [$x, $y]; |
| 860 |
} |
| 861 |
|
| 862 |
/** |
| 863 |
* Draws bar or line graph axes |
| 864 |
*/ |
| 865 |
protected function axes() |
| 866 |
{ |
| 867 |
$this->calcGrid(); |
| 868 |
$axes = $label_group = $axis_text = ''; |
| 869 |
|
| 870 |
foreach($this->x_axes as $axis_no => $axis) { |
| 871 |
if($axis !== null) { |
| 872 |
$display_axis = $this->getDisplayAxis($axis, $axis_no, 'h', 'x'); |
| 873 |
list($x, $y) = $this->getAxisLocation('h', $axis_no); |
| 874 |
$axes .= $display_axis->draw($x, $y, $this->pad_left, $this->pad_top, |
| 875 |
$this->g_width, $this->g_height); |
| 876 |
} |
| 877 |
} |
| 878 |
foreach($this->y_axes as $axis_no => $axis) { |
| 879 |
if($axis !== null) { |
| 880 |
$display_axis = $this->getDisplayAxis($axis, $axis_no, 'v', 'y'); |
| 881 |
list($x, $y) = $this->getAxisLocation('v', $axis_no); |
| 882 |
$axes .= $display_axis->draw($x, $y, $this->pad_left, $this->pad_top, |
| 883 |
$this->g_width, $this->g_height); |
| 884 |
} |
| 885 |
} |
| 886 |
|
| 887 |
return $axes; |
| 888 |
} |
| 889 |
|
| 890 |
/** |
| 891 |
* Returns a set of gridlines |
| 892 |
*/ |
| 893 |
protected function gridLines($path, $colour, $dash, $width, $more = null) |
| 894 |
{ |
| 895 |
if($path->isEmpty() || $colour == 'none') |
| 896 |
return ''; |
| 897 |
$opts = ['d' => $path, 'stroke' => new Colour($this, $colour)]; |
| 898 |
if(!empty($dash)) |
| 899 |
$opts['stroke-dasharray'] = $dash; |
| 900 |
if($width && $width != 1) |
| 901 |
$opts['stroke-width'] = $width; |
| 902 |
if(is_array($more)) |
| 903 |
$opts = array_merge($opts, $more); |
| 904 |
return $this->element('path', $opts); |
| 905 |
} |
| 906 |
|
| 907 |
/** |
| 908 |
* Returns the SVG fragment for grid background stripes |
| 909 |
*/ |
| 910 |
protected function getGridStripes() |
| 911 |
{ |
| 912 |
if(!$this->getOption('grid_back_stripe')) |
| 913 |
return ''; |
| 914 |
|
| 915 |
// use array of colours if available, otherwise stripe a single colour |
| 916 |
$colours = $this->getOption('grid_back_stripe_colour'); |
| 917 |
if(!is_array($colours)) |
| 918 |
$colours = [null, $colours]; |
| 919 |
$opacity = $this->getOption('grid_back_stripe_opacity'); |
| 920 |
|
| 921 |
$bars = ''; |
| 922 |
$c = 0; |
| 923 |
$num_colours = count($colours); |
| 924 |
$rect = [ |
| 925 |
'x' => new Number($this->pad_left), |
| 926 |
'width' => new Number($this->g_width), |
| 927 |
]; |
| 928 |
if($opacity != 1) |
| 929 |
$rect['fill-opacity'] = $opacity; |
| 930 |
$points = $this->getGridPointsY($this->main_y_axis); |
| 931 |
$first = array_shift($points); |
| 932 |
$last_pos = $first->position; |
| 933 |
foreach($points as $grid_point) { |
| 934 |
$cc = $colours[$c % $num_colours]; |
| 935 |
if($cc !== null) { |
| 936 |
$rect['y'] = $grid_point->position; |
| 937 |
$rect['height'] = $last_pos - $grid_point->position; |
| 938 |
$rect['fill'] = new Colour($this, $cc); |
| 939 |
$bars .= $this->element('rect', $rect); |
| 940 |
} |
| 941 |
$last_pos = $grid_point->position; |
| 942 |
++$c; |
| 943 |
} |
| 944 |
return $this->element('g', [], null, $bars); |
| 945 |
} |
| 946 |
|
| 947 |
/** |
| 948 |
* Returns the crosshairs code |
| 949 |
*/ |
| 950 |
protected function getCrossHairs() |
| 951 |
{ |
| 952 |
if(!$this->getOption('crosshairs')) |
| 953 |
return ''; |
| 954 |
|
| 955 |
$ch = new CrossHairs($this, $this->pad_left, $this->pad_top, |
| 956 |
$this->g_width, $this->g_height, $this->x_axes[$this->main_x_axis], |
| 957 |
$this->y_axes[$this->main_y_axis], $this->values->associativeKeys(), |
| 958 |
false, $this->encoding); |
| 959 |
|
| 960 |
if($ch->enabled()) |
| 961 |
return $ch->getCrossHairs(); |
| 962 |
return ''; |
| 963 |
} |
| 964 |
|
| 965 |
/** |
| 966 |
* Draws the grid behind the bar / line graph |
| 967 |
*/ |
| 968 |
protected function grid() |
| 969 |
{ |
| 970 |
$this->calcAxes(); |
| 971 |
$this->calcGrid(); |
| 972 |
|
| 973 |
// these are used quite a bit, so convert to Number now |
| 974 |
$left_num = new Number($this->pad_left); |
| 975 |
$top_num = new Number($this->pad_top); |
| 976 |
$width_num = new Number($this->g_width); |
| 977 |
$height_num = new Number($this->g_height); |
| 978 |
|
| 979 |
$back = $subpath = ''; |
| 980 |
$grid_group = ['class' => 'grid']; |
| 981 |
$crosshairs = $this->getCrossHairs(); |
| 982 |
|
| 983 |
// if the grid is not displayed, stop now |
| 984 |
if(!$this->getOption('show_grid') || |
| 985 |
(!$this->getOption('show_grid_h') && !$this->getOption('show_grid_v'))) |
| 986 |
return empty($crosshairs) ? '' : |
| 987 |
$this->element('g', $grid_group, null, $crosshairs); |
| 988 |
|
| 989 |
$back_colour = new Colour($this, $this->getOption('grid_back_colour')); |
| 990 |
if(!$back_colour->isNone()) { |
| 991 |
$rect = [ |
| 992 |
'x' => $left_num, 'y' => $top_num, |
| 993 |
'width' => $width_num, 'height' => $height_num, |
| 994 |
'fill' => $back_colour |
| 995 |
]; |
| 996 |
if($this->getOption('grid_back_opacity') != 1) |
| 997 |
$rect['fill-opacity'] = $this->getOption('grid_back_opacity'); |
| 998 |
$back = $this->element('rect', $rect); |
| 999 |
} |
| 1000 |
$back .= $this->getGridStripes(); |
| 1001 |
|
| 1002 |
if($this->getOption('show_grid_subdivisions')) { |
| 1003 |
$subpath_h = new PathData(); |
| 1004 |
$subpath_v = new PathData(); |
| 1005 |
if($this->getOption('show_grid_h')) { |
| 1006 |
$subdivs = $this->getSubDivsY($this->main_y_axis); |
| 1007 |
foreach($subdivs as $y) |
| 1008 |
$subpath_v->add('M', $left_num, $y->position, 'h', $width_num); |
| 1009 |
} |
| 1010 |
if($this->getOption('show_grid_v')){ |
| 1011 |
$subdivs = $this->getSubDivsX($this->main_x_axis); |
| 1012 |
foreach($subdivs as $x) |
| 1013 |
$subpath_h->add('M', $x->position, $top_num, 'v', $height_num); |
| 1014 |
} |
| 1015 |
|
| 1016 |
if(!($subpath_h->isEmpty() && $subpath_v->isEmpty())) { |
| 1017 |
$colour_h = $this->getOption('grid_subdivision_colour_h', |
| 1018 |
'grid_subdivision_colour', 'grid_colour_h', 'grid_colour'); |
| 1019 |
$colour_v = $this->getOption('grid_subdivision_colour_v', |
| 1020 |
'grid_subdivision_colour', 'grid_colour_v', 'grid_colour'); |
| 1021 |
$dash_h = $this->getOption('grid_subdivision_dash_h', |
| 1022 |
'grid_subdivision_dash', 'grid_dash_h', 'grid_dash'); |
| 1023 |
$dash_v = $this->getOption('grid_subdivision_dash_v', |
| 1024 |
'grid_subdivision_dash', 'grid_dash_v', 'grid_dash'); |
| 1025 |
$width_h = $this->getOption('grid_subdivision_stroke_width_h', |
| 1026 |
'grid_subdivision_stroke_width', 'grid_stroke_width_h', |
| 1027 |
'grid_stroke_width'); |
| 1028 |
$width_v = $this->getOption('grid_subdivision_stroke_width_v', |
| 1029 |
'grid_subdivision_stroke_width', 'grid_stroke_width_v', |
| 1030 |
'grid_stroke_width'); |
| 1031 |
|
| 1032 |
if($dash_h == $dash_v && $colour_h == $colour_v && $width_h == $width_v) { |
| 1033 |
$subpath_h->add($subpath_v); |
| 1034 |
$subpath = $this->gridLines($subpath_h, $colour_h, $dash_h, $width_h); |
| 1035 |
} else { |
| 1036 |
$subpath = $this->gridLines($subpath_h, $colour_h, $dash_h, $width_h) . |
| 1037 |
$this->gridLines($subpath_v, $colour_v, $dash_v, $width_v); |
| 1038 |
} |
| 1039 |
} |
| 1040 |
} |
| 1041 |
|
| 1042 |
$path_v = new PathData; |
| 1043 |
$path_h = new PathData; |
| 1044 |
if($this->getOption('show_grid_h')) { |
| 1045 |
$points = $this->getGridPointsY($this->main_y_axis); |
| 1046 |
foreach($points as $y) |
| 1047 |
$path_v->add('M', $left_num, $y->position, 'h', $width_num); |
| 1048 |
} |
| 1049 |
if($this->getOption('show_grid_v')) { |
| 1050 |
$points = $this->getGridPointsX($this->main_x_axis); |
| 1051 |
foreach($points as $x) |
| 1052 |
$path_h->add('M', $x->position, $top_num, 'v', $height_num); |
| 1053 |
} |
| 1054 |
|
| 1055 |
$colour_h = $this->getOption('grid_colour_h', 'grid_colour'); |
| 1056 |
$colour_v = $this->getOption('grid_colour_v', 'grid_colour'); |
| 1057 |
$dash_h = $this->getOption('grid_dash_h', 'grid_dash'); |
| 1058 |
$dash_v = $this->getOption('grid_dash_v', 'grid_dash'); |
| 1059 |
$width_h = $this->getOption('grid_stroke_width_h', 'grid_stroke_width'); |
| 1060 |
$width_v = $this->getOption('grid_stroke_width_v', 'grid_stroke_width'); |
| 1061 |
|
| 1062 |
if($dash_h == $dash_v && $colour_h == $colour_v && $width_h == $width_v) { |
| 1063 |
$path_v->add($path_h); |
| 1064 |
$path = $this->gridLines($path_v, $colour_h, $dash_h, $width_h); |
| 1065 |
} else { |
| 1066 |
$path = $this->gridLines($path_h, $colour_h, $dash_h,$width_h) . |
| 1067 |
$this->gridLines($path_v, $colour_v, $dash_v, $width_v); |
| 1068 |
} |
| 1069 |
|
| 1070 |
return $this->element('g', $grid_group, null, |
| 1071 |
$back . $subpath . $path . $crosshairs); |
| 1072 |
} |
| 1073 |
|
| 1074 |
/** |
| 1075 |
* clamps a value to the grid boundaries |
| 1076 |
*/ |
| 1077 |
protected function clampVertical($val) |
| 1078 |
{ |
| 1079 |
return max($this->pad_top, min($this->height - $this->pad_bottom, $val)); |
| 1080 |
} |
| 1081 |
|
| 1082 |
protected function clampHorizontal($val) |
| 1083 |
{ |
| 1084 |
return max($this->pad_left, min($this->width - $this->pad_right, $val)); |
| 1085 |
} |
| 1086 |
|
| 1087 |
/** |
| 1088 |
* Sets the clipping path for the grid |
| 1089 |
*/ |
| 1090 |
protected function clipGrid(&$attr) |
| 1091 |
{ |
| 1092 |
$cx = $this->getOption('grid_clip_overlap_x'); |
| 1093 |
$cy = $this->getOption('grid_clip_overlap_y'); |
| 1094 |
|
| 1095 |
$clip_id = $this->gridClipPath($cx, $cy); |
| 1096 |
$attr['clip-path'] = 'url(#' . $clip_id . ')'; |
| 1097 |
} |
| 1098 |
|
| 1099 |
/** |
| 1100 |
* Returns the ID of the grid clipping path |
| 1101 |
*/ |
| 1102 |
public function gridClipPath($x_overlap = 0, $y_overlap = 0) |
| 1103 |
{ |
| 1104 |
$cid = new Number($x_overlap) . '_' . new Number($y_overlap); |
| 1105 |
if(isset($this->grid_clip_id[$cid])) |
| 1106 |
return $this->grid_clip_id[$cid]; |
| 1107 |
|
| 1108 |
$crop_top = max(0, $this->pad_top - $y_overlap); |
| 1109 |
$crop_bottom = min($this->height, $this->height - $this->pad_bottom + $y_overlap); |
| 1110 |
$crop_left = max(0, $this->pad_left - $x_overlap); |
| 1111 |
$crop_right = min($this->width, $this->width - $this->pad_right + $x_overlap); |
| 1112 |
$rect = [ |
| 1113 |
'x' => $crop_left, 'y' => $crop_top, |
| 1114 |
'width' => $crop_right - $crop_left, |
| 1115 |
'height' => $crop_bottom - $crop_top, |
| 1116 |
]; |
| 1117 |
$clip_id = $this->newID(); |
| 1118 |
$this->defs->add($this->element('clipPath', ['id' => $clip_id], null, |
| 1119 |
$this->element('rect', $rect))); |
| 1120 |
return ($this->grid_clip_id[$cid] = $clip_id); |
| 1121 |
} |
| 1122 |
|
| 1123 |
/** |
| 1124 |
* Returns the grid position for a bar or point, or NULL if not on grid |
| 1125 |
* $item = data item |
| 1126 |
* $index = integer position in array |
| 1127 |
*/ |
| 1128 |
protected function gridPosition($item, $index) |
| 1129 |
{ |
| 1130 |
$offset = $this->x_axes[$this->main_x_axis]->position($index, $item); |
| 1131 |
$zero = -0.01; // catch values close to 0 |
| 1132 |
|
| 1133 |
if($offset >= $zero && floor($offset) <= $this->grid_limit) |
| 1134 |
return $this->pad_left + $offset; |
| 1135 |
return null; |
| 1136 |
} |
| 1137 |
|
| 1138 |
/** |
| 1139 |
* Returns an X unit value as a SVG distance |
| 1140 |
*/ |
| 1141 |
public function unitsX($x, $axis_no = null) |
| 1142 |
{ |
| 1143 |
if($axis_no === null) |
| 1144 |
$axis_no = $this->main_x_axis; |
| 1145 |
if(!isset($this->x_axes[$axis_no])) |
| 1146 |
throw new \Exception('Axis x' . $axis_no . ' does not exist'); |
| 1147 |
if($this->x_axes[$axis_no] === null) |
| 1148 |
$axis_no = $this->main_x_axis; |
| 1149 |
$axis = $this->x_axes[$axis_no]; |
| 1150 |
return $axis->position($x); |
| 1151 |
} |
| 1152 |
|
| 1153 |
/** |
| 1154 |
* Returns a Y unit value as a SVG distance |
| 1155 |
*/ |
| 1156 |
public function unitsY($y, $axis_no = null) |
| 1157 |
{ |
| 1158 |
if($axis_no === null) |
| 1159 |
$axis_no = $this->main_y_axis; |
| 1160 |
if(!isset($this->y_axes[$axis_no])) |
| 1161 |
throw new \Exception('Axis y' . $axis_no . ' does not exist'); |
| 1162 |
if($this->y_axes[$axis_no] === null) |
| 1163 |
$axis_no = $this->main_y_axis; |
| 1164 |
$axis = $this->y_axes[$axis_no]; |
| 1165 |
return $axis->position($y); |
| 1166 |
} |
| 1167 |
|
| 1168 |
/** |
| 1169 |
* Returns the $x value as a grid position |
| 1170 |
*/ |
| 1171 |
public function gridX($x, $axis_no = null) |
| 1172 |
{ |
| 1173 |
$p = $this->unitsX($x, $axis_no); |
| 1174 |
if($p !== null) |
| 1175 |
return $this->pad_left + $p; |
| 1176 |
return null; |
| 1177 |
} |
| 1178 |
|
| 1179 |
/** |
| 1180 |
* Returns the $y value as a grid position |
| 1181 |
*/ |
| 1182 |
public function gridY($y, $axis_no = null) |
| 1183 |
{ |
| 1184 |
$p = $this->unitsY($y, $axis_no); |
| 1185 |
if($p === null) |
| 1186 |
return null; |
| 1187 |
if($axis_no === null || $this->y_axes[$axis_no] === null) |
| 1188 |
$axis_no = $this->main_y_axis; |
| 1189 |
$axis = $this->y_axes[$axis_no]; |
| 1190 |
return $axis->reversed() ? $this->height - $this->pad_bottom - $p : |
| 1191 |
$this->pad_top + $p; |
| 1192 |
} |
| 1193 |
|
| 1194 |
/** |
| 1195 |
* Returns the location of the X axis origin |
| 1196 |
*/ |
| 1197 |
protected function originX($axis_no = null) |
| 1198 |
{ |
| 1199 |
if($axis_no === null || $this->x_axes[$axis_no] === null) |
| 1200 |
$axis_no = $this->main_x_axis; |
| 1201 |
$axis = $this->x_axes[$axis_no]; |
| 1202 |
return $this->pad_left + $axis->origin(); |
| 1203 |
} |
| 1204 |
|
| 1205 |
/** |
| 1206 |
* Returns the location of the Y axis origin |
| 1207 |
*/ |
| 1208 |
protected function originY($axis_no = null) |
| 1209 |
{ |
| 1210 |
if($axis_no === null || $this->y_axes[$axis_no] === null) |
| 1211 |
$axis_no = $this->main_y_axis; |
| 1212 |
$axis = $this->y_axes[$axis_no]; |
| 1213 |
return $axis->reversed() ? |
| 1214 |
$this->height - $this->pad_bottom - $axis->origin() : |
| 1215 |
$this->pad_top + $axis->origin(); |
| 1216 |
} |
| 1217 |
|
| 1218 |
/** |
| 1219 |
* Calculates the averages, storing them as guidelines |
| 1220 |
*/ |
| 1221 |
protected function calcAverages($cls = 'Goat1000\SVGGraph\Average') |
| 1222 |
{ |
| 1223 |
$show = $this->getOption('show_average'); |
| 1224 |
if(empty($show)) |
| 1225 |
return; |
| 1226 |
|
| 1227 |
$datasets = empty($this->multi_graph) ? [0] : |
| 1228 |
$this->multi_graph->getEnabledDatasets(); |
| 1229 |
|
| 1230 |
$average = new $cls($this, $this->values, $datasets); |
| 1231 |
$average->getGuidelines(); |
| 1232 |
} |
| 1233 |
|
| 1234 |
/** |
| 1235 |
* Loads the guidelines from options |
| 1236 |
*/ |
| 1237 |
protected function calcGuidelines() |
| 1238 |
{ |
| 1239 |
$this->calcAverages(); |
| 1240 |
$guidelines = $this->getOption('guideline'); |
| 1241 |
if(empty($guidelines) && $guidelines !== 0) |
| 1242 |
return; |
| 1243 |
|
| 1244 |
$this->guidelines = new Guidelines($this, false, |
| 1245 |
$this->values->associativeKeys(), |
| 1246 |
$this->getOption('datetime_keys')); |
| 1247 |
} |
| 1248 |
|
| 1249 |
public function underShapes() |
| 1250 |
{ |
| 1251 |
$content = parent::underShapes(); |
| 1252 |
if($this->guidelines !== null) |
| 1253 |
$content .= $this->guidelines->getBelow(); |
| 1254 |
return $content; |
| 1255 |
} |
| 1256 |
|
| 1257 |
public function overShapes() |
| 1258 |
{ |
| 1259 |
$content = parent::overShapes(); |
| 1260 |
if($this->guidelines !== null) |
| 1261 |
$content .= $this->guidelines->getAbove(); |
| 1262 |
return $content; |
| 1263 |
} |
| 1264 |
} |
| 1265 |
|
| 1266 |
|