PluginProbe
E2Pdf – Export Pdf Tool for WordPress / 1.32.49
E2Pdf – Export Pdf Tool for WordPress v1.32.49
1.32.49 1.32.48 1.32.43 1.32.40 1.32.34 1.32.32 1.32.31 1.32.26 1.32.22 1.32.23 1.32.18 1.32.17 1.32.15 trunk 1.00.00 1.00.13 1.01.01 1.02.02 1.03.07 1.04.07 1.05.03 1.06.02 1.07.11 1.08.00 1.08.06 All 72 releases
e2pdf / vendors / svggraph / GridGraph.php

GridGraph.php in E2Pdf – Export Pdf Tool for WordPress 1.32.49, at vendors/svggraph/GridGraph.php

1,266 lines 39.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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