| 1 |
<?php |
| 2 |
/** |
| 3 |
* Copyright (C) 2019-2023 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 |
/** |
| 25 |
* Base class for all graph types |
| 26 |
*/ |
| 27 |
abstract class Graph { |
| 28 |
|
| 29 |
public $subgraph = false; |
| 30 |
protected $width = 0; |
| 31 |
protected $height = 0; |
| 32 |
protected $pad_left = 0; |
| 33 |
protected $pad_right = 0; |
| 34 |
protected $pad_top = 0; |
| 35 |
protected $pad_bottom = 0; |
| 36 |
protected $settings = []; |
| 37 |
protected $values = []; |
| 38 |
protected $namespace = false; |
| 39 |
protected $link_base = ''; |
| 40 |
protected $link_target = '_blank'; |
| 41 |
protected $links = []; |
| 42 |
public $encoding = 'UTF-8'; |
| 43 |
|
| 44 |
protected $colours = null; |
| 45 |
public $defs = null; |
| 46 |
public $figures = null; |
| 47 |
protected $subgraphs = []; |
| 48 |
protected $back_matter = ''; |
| 49 |
|
| 50 |
protected $namespaces = []; |
| 51 |
private static $last_id = 0; |
| 52 |
public static $key_format = null; |
| 53 |
protected $legend = null; |
| 54 |
protected $data_label_style_cache = []; |
| 55 |
protected $multi_graph; |
| 56 |
|
| 57 |
private static $javascript = null; |
| 58 |
private $data_labels = null; |
| 59 |
private $context_menu = null; |
| 60 |
private $shapes = null; |
| 61 |
|
| 62 |
/** |
| 63 |
* @arg $w = width |
| 64 |
* @arg $h = height |
| 65 |
* @arg $settings = user options |
| 66 |
* @arg $fixed_settings = class options overriding user options |
| 67 |
*/ |
| 68 |
public function __construct($w, $h, array $settings, array $fixed_settings = []) |
| 69 |
{ |
| 70 |
$this->width = $w; |
| 71 |
$this->height = $h; |
| 72 |
$this->defs = new Defs($this); |
| 73 |
|
| 74 |
// get settings from ini file that are relevant to this class |
| 75 |
$class = get_class($this); |
| 76 |
$ini_settings = $this->ini_settings($class); |
| 77 |
|
| 78 |
// default option overrides - subclasses can override these |
| 79 |
$fixed_setting_defaults = [ |
| 80 |
'repeated_keys' => 'error', |
| 81 |
'sort_keys' => true, |
| 82 |
'require_structured' => false, |
| 83 |
'require_integer_keys' => true, |
| 84 |
]; |
| 85 |
$this->settings = array_merge($this->settings, $ini_settings, $settings, |
| 86 |
$fixed_setting_defaults, $fixed_settings); |
| 87 |
|
| 88 |
// set up figures - it can't be dynamic because figures can refer |
| 89 |
// to other figures via Markers |
| 90 |
$this->figures = new Figures($this, $this->settings); |
| 91 |
|
| 92 |
// copy some settings to member variables |
| 93 |
$opts = ['namespace', 'link_base', 'link_target', 'encoding', |
| 94 |
'pad_top', 'pad_bottom', 'pad_left', 'pad_right']; |
| 95 |
foreach($opts as $opt) |
| 96 |
$this->{$opt} = $this->getOption($opt); |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Deprecated option/member access - display error and fail |
| 101 |
*/ |
| 102 |
public function __get($name) |
| 103 |
{ |
| 104 |
trigger_error('Attempt to get $this->' . $name, E_USER_WARNING); |
| 105 |
debug_print_backtrace(0, 1); |
| 106 |
exit; |
| 107 |
} |
| 108 |
public function __isset($name) |
| 109 |
{ |
| 110 |
trigger_error('Isset attempt on option $this->' . $name, E_USER_WARNING); |
| 111 |
debug_print_backtrace(0, 1); |
| 112 |
exit; |
| 113 |
} |
| 114 |
public function __set($name, $value) |
| 115 |
{ |
| 116 |
trigger_error('Attempt to set $this->' . $name, E_USER_WARNING); |
| 117 |
debug_print_backtrace(0, 1); |
| 118 |
exit; |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Returns the settings from the ini file for a class |
| 123 |
*/ |
| 124 |
protected function ini_settings($class) |
| 125 |
{ |
| 126 |
$ini_file = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'svggraph.ini'; |
| 127 |
$ini_settings = false; |
| 128 |
if(file_exists($ini_file)) |
| 129 |
$ini_settings = parse_ini_file($ini_file, true); |
| 130 |
if($ini_settings === false) |
| 131 |
throw new \Exception('INI file [' . $ini_file . '] could not be loaded'); |
| 132 |
|
| 133 |
$hierarchy = [$class]; |
| 134 |
while($class = get_parent_class($class)) |
| 135 |
array_unshift($hierarchy, $class); |
| 136 |
|
| 137 |
$settings = []; |
| 138 |
while(count($hierarchy)) { |
| 139 |
$class = array_shift($hierarchy); |
| 140 |
$ns = strrpos($class, '\\'); |
| 141 |
$class = substr($class, $ns + 1); |
| 142 |
if(array_key_exists($class, $ini_settings)) |
| 143 |
$settings = array_merge($settings, $ini_settings[$class]); |
| 144 |
} |
| 145 |
|
| 146 |
return $settings; |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Sets the graph values |
| 151 |
*/ |
| 152 |
public function values($values) |
| 153 |
{ |
| 154 |
$new_values = []; |
| 155 |
$v = func_get_args(); |
| 156 |
if(count($v) == 1) |
| 157 |
$v = array_shift($v); |
| 158 |
|
| 159 |
$set_values = true; |
| 160 |
if(is_array($v)) { |
| 161 |
reset($v); |
| 162 |
$first_key = key($v); |
| 163 |
if($first_key !== null && is_array($v[$first_key])) { |
| 164 |
foreach($v as $data_set) |
| 165 |
$new_values[] = $data_set; |
| 166 |
$set_values = false; |
| 167 |
} |
| 168 |
} |
| 169 |
|
| 170 |
if($set_values) |
| 171 |
$new_values[] = $v; |
| 172 |
|
| 173 |
$require_structured = $this->getOption('require_structured'); |
| 174 |
$structured_data = $this->getOption('structured_data'); |
| 175 |
$structure = $this->getOption('structure'); |
| 176 |
$datetime_keys = $this->getOption('datetime_keys'); |
| 177 |
$datetime_key_format = $this->getOption('datetime_key_format'); |
| 178 |
$force_assoc = $this->getOption('force_assoc'); |
| 179 |
|
| 180 |
if($this->getOption('scatter_2d')) { |
| 181 |
$this->setOption('scatter_2d', false); |
| 182 |
if(empty($structure)) { |
| 183 |
$structure = ['key' => 0, 'value' => 1, 'datasets' => true]; |
| 184 |
$this->setOption('structure', $structure); |
| 185 |
} |
| 186 |
} |
| 187 |
|
| 188 |
if($datetime_keys && $datetime_key_format) |
| 189 |
Graph::$key_format = $datetime_key_format; |
| 190 |
|
| 191 |
if($structured_data || is_array($structure)) { |
| 192 |
$this->setOption('structured_data', true); |
| 193 |
$this->values = new StructuredData($new_values, $force_assoc, |
| 194 |
$datetime_keys, $structure, |
| 195 |
$this->getOption('repeated_keys'), $this->getOption('sort_keys'), |
| 196 |
$this->getOption('require_integer_keys'), $require_structured); |
| 197 |
} else { |
| 198 |
$this->values = new Data($new_values, $force_assoc, $datetime_keys); |
| 199 |
if(!$this->values->error && !empty($require_structured)) |
| 200 |
$this->values->error = get_class($this) . ' requires structured data'; |
| 201 |
} |
| 202 |
|
| 203 |
if($this->values->error) |
| 204 |
return; |
| 205 |
|
| 206 |
$dataset = $this->getOption('dataset', 0); |
| 207 |
if($dataset === 0) |
| 208 |
return; |
| 209 |
|
| 210 |
$dcount = count($this->values); |
| 211 |
$dataset = $this->getOption(['dataset', 0], 0); |
| 212 |
if($dcount <= $dataset) { |
| 213 |
$this->values->error = 'No valid datasets selected'; |
| 214 |
return; |
| 215 |
} |
| 216 |
|
| 217 |
// dataset option doesn't work well without structured data |
| 218 |
$this->values = StructuredData::convertFrom($this->values, $force_assoc, |
| 219 |
$datetime_keys, $this->getOption('require_integer_keys')); |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Sets the links from each item |
| 224 |
*/ |
| 225 |
public function links() |
| 226 |
{ |
| 227 |
$this->links = func_get_args(); |
| 228 |
} |
| 229 |
|
| 230 |
/** |
| 231 |
* Assigns the list of subgraphs |
| 232 |
*/ |
| 233 |
public function subgraphs($subgraphs) |
| 234 |
{ |
| 235 |
$this->subgraphs = $subgraphs; |
| 236 |
} |
| 237 |
|
| 238 |
/** |
| 239 |
* Set up the colours |
| 240 |
*/ |
| 241 |
public function colours(Colours $colours) |
| 242 |
{ |
| 243 |
$this->colours = $colours; |
| 244 |
} |
| 245 |
|
| 246 |
public function getMinValue() |
| 247 |
{ |
| 248 |
$d = $this->getOption(['dataset', 0], 0); |
| 249 |
return $this->values->getMinValue($d); |
| 250 |
} |
| 251 |
public function getMaxValue() |
| 252 |
{ |
| 253 |
$d = $this->getOption(['dataset', 0], 0); |
| 254 |
return $this->values->getMaxValue($d); |
| 255 |
} |
| 256 |
public function getMinKey() |
| 257 |
{ |
| 258 |
$d = $this->getOption(['dataset', 0], 0); |
| 259 |
return $this->values->getMinKey($d); |
| 260 |
} |
| 261 |
public function getMaxKey() |
| 262 |
{ |
| 263 |
$d = $this->getOption(['dataset', 0], 0); |
| 264 |
return $this->values->getMaxKey($d); |
| 265 |
} |
| 266 |
public function getKey($i) |
| 267 |
{ |
| 268 |
return $this->values->getKey($i); |
| 269 |
} |
| 270 |
|
| 271 |
/** |
| 272 |
* Sets up the colours used for the graph |
| 273 |
*/ |
| 274 |
protected function setup() |
| 275 |
{ |
| 276 |
$dataset = $this->getOption(['dataset', 0], 0); |
| 277 |
$this->colourSetup($this->values->itemsCount($dataset)); |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* Returns the Javascript instance |
| 282 |
*/ |
| 283 |
public function getJavascript() |
| 284 |
{ |
| 285 |
if(!isset(Graph::$javascript)) |
| 286 |
Graph::$javascript = new Javascript($this); |
| 287 |
return Graph::$javascript; |
| 288 |
} |
| 289 |
|
| 290 |
/** |
| 291 |
* Returns the DataLabels instance |
| 292 |
*/ |
| 293 |
public function getDataLabels() |
| 294 |
{ |
| 295 |
if($this->data_labels === null) |
| 296 |
$this->data_labels = new DataLabels($this); |
| 297 |
return $this->data_labels; |
| 298 |
} |
| 299 |
|
| 300 |
/** |
| 301 |
* Draws the selected graph |
| 302 |
*/ |
| 303 |
public function drawGraph() |
| 304 |
{ |
| 305 |
$canvas_id = $this->newID(); |
| 306 |
$this->initLegend(); |
| 307 |
$this->setup(); |
| 308 |
if(!is_numeric($this->width)) |
| 309 |
$this->width = 640; |
| 310 |
if(!is_numeric($this->height)) |
| 311 |
$this->height = 480; |
| 312 |
|
| 313 |
$contents = $this->canvas($canvas_id); |
| 314 |
$contents .= $this->drawTitle(); |
| 315 |
$contents .= $this->draw(); |
| 316 |
$contents .= $this->drawDataLabels(); |
| 317 |
$contents .= $this->drawBackMatter(); |
| 318 |
$contents .= $this->drawLegend(); |
| 319 |
|
| 320 |
foreach($this->subgraphs as $subgraph) |
| 321 |
$contents .= $subgraph->fetch($this); |
| 322 |
|
| 323 |
// magnifying means everything must be in a group for transformation |
| 324 |
if(!$this->subgraph && $this->getOption('magnify')) { |
| 325 |
$this->getJavascript()->magnifier(); |
| 326 |
$group = ['class' => 'svggraph-magnifier']; |
| 327 |
$contents = $this->element('g', $group, null, $contents); |
| 328 |
} |
| 329 |
|
| 330 |
// rounded rects might need a clip path |
| 331 |
if($this->getOption('back_round') && $this->getOption('back_round_clip')) { |
| 332 |
$group = ['clip-path' => 'url(#' . $canvas_id . ')']; |
| 333 |
return $this->element('g', $group, null, $contents); |
| 334 |
} |
| 335 |
return $contents; |
| 336 |
} |
| 337 |
|
| 338 |
/** |
| 339 |
* Adds any markup that goes after the graph |
| 340 |
*/ |
| 341 |
protected function drawBackMatter() |
| 342 |
{ |
| 343 |
return $this->back_matter; |
| 344 |
} |
| 345 |
|
| 346 |
/** |
| 347 |
* Sets up the legend class |
| 348 |
*/ |
| 349 |
protected function initLegend() |
| 350 |
{ |
| 351 |
$this->legend = null; |
| 352 |
|
| 353 |
// see if the legend is needed |
| 354 |
if(!$this->getOption('show_legend')) |
| 355 |
return; |
| 356 |
|
| 357 |
$entries = $this->getOption('legend_entries'); |
| 358 |
$structure = $this->getOption('structure'); |
| 359 |
if(empty($entries) && !isset($structure['legend_text'])) |
| 360 |
return; |
| 361 |
|
| 362 |
$this->legend = new Legend($this); |
| 363 |
} |
| 364 |
|
| 365 |
/** |
| 366 |
* Returns the ordering for legend entries |
| 367 |
*/ |
| 368 |
public function getLegendOrder() |
| 369 |
{ |
| 370 |
// null for no special order |
| 371 |
return null; |
| 372 |
} |
| 373 |
|
| 374 |
/** |
| 375 |
* Draws the legend |
| 376 |
*/ |
| 377 |
protected function drawLegend() |
| 378 |
{ |
| 379 |
if($this->legend === null) |
| 380 |
return ''; |
| 381 |
return $this->legend->draw(); |
| 382 |
} |
| 383 |
|
| 384 |
/** |
| 385 |
* Parses a position string, returning x and y coordinates |
| 386 |
*/ |
| 387 |
public function parsePosition($pos, $w = 0, $h = 0, $pad = 0) |
| 388 |
{ |
| 389 |
$inner = true; |
| 390 |
$parts = preg_split('/\s+/', $pos); |
| 391 |
if(count($parts)) { |
| 392 |
// if 'outer' is found after 'inner', it takes precedence |
| 393 |
$parts = array_reverse($parts); |
| 394 |
$inner_at = array_search('inner', $parts); |
| 395 |
$outer_at = array_search('outer', $parts); |
| 396 |
|
| 397 |
if($outer_at !== false && ($inner_at === false || $inner_at < $outer_at)) |
| 398 |
$inner = false; |
| 399 |
} |
| 400 |
|
| 401 |
if($inner) { |
| 402 |
$t = $this->pad_top; |
| 403 |
$l = $this->pad_left; |
| 404 |
$b = $this->height - $this->pad_bottom; |
| 405 |
$r = $this->width - $this->pad_right; |
| 406 |
// make sure it fits to keep RelativePosition happy |
| 407 |
if($w > $r - $l) $w = $r - $l; |
| 408 |
if($h > $b - $t) $h = $b - $t; |
| 409 |
} else { |
| 410 |
$t = $l = 0; |
| 411 |
$b = $this->height; |
| 412 |
$r = $this->width; |
| 413 |
} |
| 414 |
|
| 415 |
// ParsePosition is always inside canvas or graph, defaulted to top left |
| 416 |
$pos = 'top left ' . str_replace('outer', 'inner', $pos); |
| 417 |
return Graph::relativePosition($pos, $t, $l, $b, $r, $w, $h, $pad); |
| 418 |
} |
| 419 |
|
| 420 |
/** |
| 421 |
* Returns [hpos,vpos,offset_x,offset_y] positions derived from full |
| 422 |
* position string |
| 423 |
*/ |
| 424 |
public static function translatePosition($pos) |
| 425 |
{ |
| 426 |
$parts = preg_split('/\s+/', strtolower($pos)); |
| 427 |
$offset_x = $offset_y = 0; |
| 428 |
$inside = true; |
| 429 |
$vpos = 'm'; |
| 430 |
$hpos = 'c'; |
| 431 |
|
| 432 |
// translated positions: |
| 433 |
// ot, t, m, b, ob = outside top, top, middle, bottom, outside bottom |
| 434 |
// ol, l, c, r, or = outside left, left, centre, right, outside right |
| 435 |
while(count($parts)) { |
| 436 |
$part = array_shift($parts); |
| 437 |
switch($part) { |
| 438 |
case 'outer' : |
| 439 |
case 'outside' : $inside = false; |
| 440 |
break; |
| 441 |
case 'inner' : |
| 442 |
case 'inside' : $inside = true; |
| 443 |
break; |
| 444 |
case 'top' : $vpos = $inside ? 't' : 'ot'; |
| 445 |
break; |
| 446 |
case 'bottom' : $vpos = $inside ? 'b' : 'ob'; |
| 447 |
break; |
| 448 |
case 'left' : $hpos = $inside ? 'l' : 'ol'; |
| 449 |
break; |
| 450 |
case 'right' : $hpos = $inside ? 'r' : 'or'; |
| 451 |
break; |
| 452 |
case 'above' : $inside = false; $vpos = 'ot'; |
| 453 |
break; |
| 454 |
case 'below' : $inside = false; $vpos = 'ob'; |
| 455 |
break; |
| 456 |
case 'center' : $hpos = $inside ? 'c' : 'oc'; |
| 457 |
break; |
| 458 |
case 'middle' : $vpos = $inside ? 'm' : 'om'; |
| 459 |
break; |
| 460 |
default: |
| 461 |
if(is_numeric($part)) { |
| 462 |
$offset_x = $part; |
| 463 |
if(count($parts) && is_numeric($parts[0])) |
| 464 |
$offset_y = array_shift($parts); |
| 465 |
} |
| 466 |
} |
| 467 |
} |
| 468 |
return [$hpos, $vpos, $offset_x, $offset_y]; |
| 469 |
} |
| 470 |
|
| 471 |
/** |
| 472 |
* Returns [x,y,text-anchor,hpos,vpos] position that is $pos relative to the |
| 473 |
* top, left, bottom and right. |
| 474 |
* When $text is true, x and y are adjusted for text-anchor position |
| 475 |
*/ |
| 476 |
public static function relativePosition($pos, $top, $left, |
| 477 |
$bottom, $right, $width, $height, $pad, $text = false) |
| 478 |
{ |
| 479 |
list($hpos, $vpos, $offset_x, $offset_y) = Graph::translatePosition($pos); |
| 480 |
|
| 481 |
// if the containers have no thickness, position outside |
| 482 |
$translate = ['l' => 'ol', 'r' => 'or', 't' => 'ot', 'b' => 'ob', 'c' => 'oc', 'm' => 'om']; |
| 483 |
if($top == $bottom && isset($translate[$vpos])) |
| 484 |
$vpos = $translate[$vpos]; |
| 485 |
if($left == $right && isset($translate[$hpos])) |
| 486 |
$hpos = $translate[$hpos]; |
| 487 |
|
| 488 |
switch($vpos) { |
| 489 |
case 'ot' : $y = $top - $height - $pad; break; |
| 490 |
case 't' : $y = $top + $pad; break; |
| 491 |
case 'b' : $y = $bottom - $height - $pad; break; |
| 492 |
case 'ob' : $y = $bottom + $pad; break; |
| 493 |
case 'om': $y = $top + ($bottom - $top - $height) / 2; break; |
| 494 |
case 'm' : |
| 495 |
default : |
| 496 |
$y = $top + ($bottom - $top - $height) / 2; break; |
| 497 |
} |
| 498 |
|
| 499 |
if(($hpos == 'r' || $hpos == 'l') && $right - $left - $pad - $width < 0) |
| 500 |
$hpos = 'c'; |
| 501 |
switch($hpos) { |
| 502 |
case 'ol' : $x = $left - $width - $pad; break; |
| 503 |
case 'l' : $x = $left + $pad; break; |
| 504 |
case 'r' : $x = $right - $width - $pad; break; |
| 505 |
case 'or' : $x = $right + $pad; break; |
| 506 |
case 'oc' : $x = $left + ($right - $left - $width - $pad) / 2; break; |
| 507 |
case 'c' : |
| 508 |
default : |
| 509 |
$x = $left + ($right - $left - $width) / 2; break; |
| 510 |
} |
| 511 |
|
| 512 |
$y += $offset_y; |
| 513 |
$x += $offset_x; |
| 514 |
|
| 515 |
// third return value is text alignment |
| 516 |
$align_map = [ |
| 517 |
'ol' => 'end', 'l' => 'start', 'c' => 'middle', |
| 518 |
'r' => 'end', 'or' => 'start' |
| 519 |
]; |
| 520 |
$text_align = $align_map[$hpos]; |
| 521 |
|
| 522 |
// in text mode, adjust X for text alignment |
| 523 |
if($text && $hpos != 'l' && $hpos != 'or') { |
| 524 |
if($hpos == 'c') |
| 525 |
$x += $width / 2; |
| 526 |
else |
| 527 |
$x += $width; |
| 528 |
} |
| 529 |
return [$x, $y, $text_align, $hpos, $vpos]; |
| 530 |
} |
| 531 |
|
| 532 |
/** |
| 533 |
* Sets the style info for the legend |
| 534 |
*/ |
| 535 |
protected function setLegendEntry($dataset, $index, $item, $style_info) |
| 536 |
{ |
| 537 |
if($this->legend === null) |
| 538 |
return; |
| 539 |
$this->legend->setEntry($dataset, $index, $item, $style_info); |
| 540 |
} |
| 541 |
|
| 542 |
/** |
| 543 |
* Subclasses must draw the entry, if they can |
| 544 |
*/ |
| 545 |
protected function drawLegendEntry($x, $y, $w, $h, $entry) |
| 546 |
{ |
| 547 |
return ''; |
| 548 |
} |
| 549 |
|
| 550 |
/** |
| 551 |
* Returns details of title and subtitle |
| 552 |
*/ |
| 553 |
protected function getTitle() |
| 554 |
{ |
| 555 |
$info = [ |
| 556 |
'title' => $this->getOption('graph_title'), |
| 557 |
'font' => $this->getOption('graph_title_font'), |
| 558 |
'font_size' => 0, // 0 font size = no title |
| 559 |
'sfont_size' => 0, // 0 = no subtitle |
| 560 |
]; |
| 561 |
|
| 562 |
$svg_text = new Text($this, $info['font']); |
| 563 |
if($svg_text->strlen($info['title']) <= 0) |
| 564 |
return $info; |
| 565 |
|
| 566 |
$info['font_size'] = Number::units($this->getOption('graph_title_font_size')); |
| 567 |
$info['weight'] = $this->getOption('graph_title_font_weight'); |
| 568 |
$info['colour'] = $this->getOption('graph_title_colour'); |
| 569 |
$info['pos'] = $this->getOption('graph_title_position'); |
| 570 |
$info['space'] = $this->getOption('graph_title_space'); |
| 571 |
$info['line_spacing'] = Number::units($this->getOption('graph_title_line_spacing')); |
| 572 |
if($info['line_spacing'] === null || $info['line_spacing'] < 1) |
| 573 |
$info['line_spacing'] = $info['font_size']; |
| 574 |
|
| 575 |
if($info['pos'] != 'bottom' && $info['pos'] != 'left' && $info['pos'] != 'right') |
| 576 |
$info['pos'] = 'top'; |
| 577 |
list($width, $height) = $svg_text->measure($info['title'], $info['font_size'], |
| 578 |
0, $info['line_spacing']); |
| 579 |
$info['width'] = $width; |
| 580 |
$info['height'] = $height; |
| 581 |
|
| 582 |
// now deal with sub-title |
| 583 |
$info['stitle'] = $this->getOption('graph_subtitle'); |
| 584 |
$info['sfont'] = $this->getOption('graph_subtitle_font', 'graph_title_font'); |
| 585 |
$svg_subtext = new Text($this, $info['sfont']); |
| 586 |
|
| 587 |
if($svg_subtext->strlen($info['stitle']) > 0) { |
| 588 |
$info['sfont_size'] = Number::units($this->getOption('graph_subtitle_font_size', |
| 589 |
'graph_title_font_size')); |
| 590 |
$info['sweight'] = $this->getOption('graph_subtitle_font_weight', |
| 591 |
'graph_title_font_weight'); |
| 592 |
$info['scolour'] = $this->getOption('graph_subtitle_colour', |
| 593 |
'graph_title_colour'); |
| 594 |
$info['sspace'] = $this->getOption('graph_subtitle_space', |
| 595 |
'graph_title_space'); |
| 596 |
$info['sline_spacing'] = Number::units($this->getOption('graph_subtitle_line_spacing')); |
| 597 |
if($info['sline_spacing'] === null || $info['sline_spacing'] < 1) |
| 598 |
$info['sline_spacing'] = $info['sfont_size']; |
| 599 |
|
| 600 |
list($swidth, $sheight) = $svg_subtext->measure($info['stitle'], |
| 601 |
$info['sfont_size'], 0, $info['sline_spacing']); |
| 602 |
$info['swidth'] = $swidth; |
| 603 |
$info['sheight'] = $sheight; |
| 604 |
} |
| 605 |
|
| 606 |
return $info; |
| 607 |
} |
| 608 |
|
| 609 |
/** |
| 610 |
* Draws the graph title, if there is one |
| 611 |
*/ |
| 612 |
protected function drawTitle() |
| 613 |
{ |
| 614 |
$info = $this->getTitle(); |
| 615 |
if($info['font_size'] == 0) |
| 616 |
return ''; |
| 617 |
|
| 618 |
$svg_text = new Text($this, $info['font']); |
| 619 |
$baseline = $svg_text->baseline($info['font_size']); |
| 620 |
$text = [ |
| 621 |
'font-size' => $info['font_size'], |
| 622 |
'font-family' => $info['font'], |
| 623 |
'font-weight' => $info['weight'], |
| 624 |
'text-anchor' => 'middle', |
| 625 |
'fill' => new Colour($this, $info['colour']), |
| 626 |
]; |
| 627 |
|
| 628 |
// ensure outside padding is at least the title space |
| 629 |
$pad_side = 'pad_' . $info['pos']; |
| 630 |
if($this->{$pad_side} < $info['space']) |
| 631 |
$this->{$pad_side} = $info['space']; |
| 632 |
|
| 633 |
$xform = new Transform; |
| 634 |
switch($info['pos']) { |
| 635 |
case 'left': |
| 636 |
$text['x'] = $this->pad_left + $baseline; |
| 637 |
$text['y'] = $this->height / 2; |
| 638 |
$xform->rotate(270, $text['x'], $text['y']); |
| 639 |
$text['transform'] = $xform; |
| 640 |
break; |
| 641 |
case 'right': |
| 642 |
$text['x'] = $this->width - $this->pad_right - $baseline; |
| 643 |
$text['y'] = $this->height / 2; |
| 644 |
$xform->rotate(90, $text['x'], $text['y']); |
| 645 |
$text['transform'] = $xform; |
| 646 |
break; |
| 647 |
case 'bottom': |
| 648 |
$text['x'] = $this->width / 2; |
| 649 |
$text['y'] = $this->height - $this->pad_bottom - $info['height'] + $baseline; |
| 650 |
break; |
| 651 |
default: |
| 652 |
$text['x'] = $this->width / 2; |
| 653 |
$text['y'] = $this->pad_top + $baseline; |
| 654 |
} |
| 655 |
// increase padding by size of text |
| 656 |
$this->{$pad_side} += $info['height'] + $info['space']; |
| 657 |
|
| 658 |
// now deal with sub-title |
| 659 |
$subtitle_text = ''; |
| 660 |
if($info['sfont_size'] != 0) { |
| 661 |
|
| 662 |
$svg_subtext = new Text($this, $info['sfont']); |
| 663 |
$sbaseline = $svg_subtext->baseline($info['sfont_size']); |
| 664 |
$stext = [ |
| 665 |
'font-size' => $info['sfont_size'], |
| 666 |
'font-family' => $info['sfont'], |
| 667 |
'font-weight' => $info['sweight'], |
| 668 |
'text-anchor' => 'middle', |
| 669 |
'fill' => new Colour($this, $info['scolour']), |
| 670 |
]; |
| 671 |
|
| 672 |
$sxform = new Transform; |
| 673 |
$sub_offset = $sbaseline + $info['sspace'] - $info['space']; |
| 674 |
switch($info['pos']) { |
| 675 |
case 'left': |
| 676 |
$stext['x'] = $this->pad_left + $sub_offset; |
| 677 |
$stext['y'] = $text['y']; |
| 678 |
$sxform->rotate(270, $stext['x'], $stext['y']); |
| 679 |
$stext['transform'] = $sxform; |
| 680 |
break; |
| 681 |
case 'right': |
| 682 |
$stext['x'] = $this->width - $this->pad_right - $sub_offset; |
| 683 |
$stext['y'] = $text['y']; |
| 684 |
$sxform->rotate(90, $stext['x'], $stext['y']); |
| 685 |
$stext['transform'] = $sxform; |
| 686 |
break; |
| 687 |
case 'bottom': |
| 688 |
// complicates things - need to shift title up |
| 689 |
$stext['x'] = $text['x']; |
| 690 |
$stext['y'] = $text['y'] - $baseline + $info['height'] + $sbaseline - $info['sheight']; |
| 691 |
$text['y'] -= $info['sheight'] + $info['sspace']; |
| 692 |
break; |
| 693 |
default: |
| 694 |
$stext['x'] = $text['x']; |
| 695 |
$stext['y'] = $this->pad_top + $sub_offset; |
| 696 |
} |
| 697 |
|
| 698 |
$this->{$pad_side} += $info['sheight'] + $info['sspace']; |
| 699 |
$subtitle_text = $svg_subtext->text($info['stitle'], $info['sline_spacing'], $stext); |
| 700 |
} |
| 701 |
|
| 702 |
// the Text function will break it into lines |
| 703 |
$title_text = $svg_text->text($info['title'], $info['line_spacing'], $text); |
| 704 |
|
| 705 |
return $title_text . $subtitle_text; |
| 706 |
} |
| 707 |
|
| 708 |
/** |
| 709 |
* This should be overridden by subclass! |
| 710 |
*/ |
| 711 |
abstract protected function draw(); |
| 712 |
|
| 713 |
/** |
| 714 |
* Displays the background image |
| 715 |
*/ |
| 716 |
protected function backgroundImage($shadow, $clip_path) |
| 717 |
{ |
| 718 |
$image_src = $this->getOption('back_image'); |
| 719 |
if(!$image_src) |
| 720 |
return ''; |
| 721 |
|
| 722 |
$width = $this->getOption('back_image_width'); |
| 723 |
$height = $this->getOption('back_image_height'); |
| 724 |
$left = $this->getOption('back_image_left'); |
| 725 |
$top = $this->getOption('back_image_top'); |
| 726 |
$mode = $this->getOption('back_image_mode'); |
| 727 |
|
| 728 |
if($shadow) { |
| 729 |
if($width === '100%') |
| 730 |
$width = new Number($this->width - $shadow); |
| 731 |
if($height === '100%') |
| 732 |
$height = new Number($this->height - $shadow); |
| 733 |
} |
| 734 |
|
| 735 |
$image = [ |
| 736 |
'width' => $width, 'height' => $height, |
| 737 |
'x' => $left, 'y' => $top, |
| 738 |
'xlink:href' => $image_src, |
| 739 |
'preserveAspectRatio' => |
| 740 |
($mode == 'stretch' ? 'none' : 'xMinYMin') |
| 741 |
]; |
| 742 |
|
| 743 |
if($clip_path !== null) |
| 744 |
$image['clip-path'] = 'url(#' . $clip_path . ')'; |
| 745 |
|
| 746 |
$style = []; |
| 747 |
if($this->getOption('back_image_opacity')) |
| 748 |
$style['opacity'] = $this->getOption('back_image_opacity'); |
| 749 |
|
| 750 |
$contents = ''; |
| 751 |
if($mode == 'tile') { |
| 752 |
$image['x'] = 0; $image['y'] = 0; |
| 753 |
$im = $this->element('image', $image, $style); |
| 754 |
$pattern = [ |
| 755 |
'id' => $this->newID(), |
| 756 |
'width' => $width, 'height' => $height, |
| 757 |
'x' => $left, 'y' => $top, |
| 758 |
'patternUnits' => 'userSpaceOnUse' |
| 759 |
]; |
| 760 |
// tiled image becomes a pattern to replace background colour |
| 761 |
$this->defs->add($this->element('pattern', $pattern, null, $im)); |
| 762 |
$this->setOption('back_colour', 'url(#' . $pattern['id'] . ')'); |
| 763 |
} else { |
| 764 |
$im = $this->element('image', $image, $style); |
| 765 |
$contents .= $im; |
| 766 |
} |
| 767 |
return $contents; |
| 768 |
} |
| 769 |
|
| 770 |
/** |
| 771 |
* Displays the background |
| 772 |
*/ |
| 773 |
protected function canvas($id) |
| 774 |
{ |
| 775 |
$round = $this->getOption('back_round'); |
| 776 |
$stroke_width = $this->getOption('back_stroke_width'); |
| 777 |
$stroke_colour = new Colour($this, $this->getOption('back_stroke_colour')); |
| 778 |
$shadow_opacity = $this->getOption('back_shadow_opacity'); |
| 779 |
$shadow_blur = $this->getOption('back_shadow_blur'); |
| 780 |
$shadow = $this->getOption('back_shadow'); |
| 781 |
if($shadow === true) |
| 782 |
$shadow = 2; |
| 783 |
|
| 784 |
// background image can replace the back_colour option |
| 785 |
$bg = $this->backgroundImage($shadow, $round ? $id : null); |
| 786 |
$colour = new Colour($this, $this->getOption('back_colour')); |
| 787 |
|
| 788 |
$canvas = [ |
| 789 |
'width' => '100%', 'height' => '100%', |
| 790 |
'fill' => $colour, |
| 791 |
'stroke-width' => 0, |
| 792 |
]; |
| 793 |
$c_el = ''; |
| 794 |
|
| 795 |
if($shadow) { |
| 796 |
// shadow means canvas cannot be 100% of document |
| 797 |
$canvas['x'] = $canvas['y'] = new Number($stroke_width / 2); |
| 798 |
|
| 799 |
// blurring means using a filter and a larger offset |
| 800 |
if($shadow_blur) { |
| 801 |
$filter_opts = [ |
| 802 |
'offset_x' => $shadow, |
| 803 |
'offset_y' => $shadow, |
| 804 |
'blur' => $shadow_blur, |
| 805 |
'opacity' => $shadow_opacity, |
| 806 |
'shadow_only' => true, |
| 807 |
]; |
| 808 |
|
| 809 |
$shadow += $shadow_blur; |
| 810 |
$filter = $this->defs->addFilter('shadow', $filter_opts); |
| 811 |
} |
| 812 |
$canvas['width'] = new Number($this->width - $shadow - $stroke_width); |
| 813 |
$canvas['height'] = new Number($this->height - $shadow - $stroke_width); |
| 814 |
|
| 815 |
$filled = [ |
| 816 |
//'x' => $shadow, 'y' => $shadow, |
| 817 |
'width' => new Number($this->width - $shadow), |
| 818 |
'height' => new Number($this->height - $shadow), |
| 819 |
'fill' => '#000', |
| 820 |
'stroke-width' => 0, |
| 821 |
//'opacity' => $shadow_opacity, |
| 822 |
]; |
| 823 |
|
| 824 |
if($shadow_blur) { |
| 825 |
$filled['filter'] = 'url(#' . $filter . ')'; |
| 826 |
} else { |
| 827 |
$filled['x'] = $shadow; |
| 828 |
$filled['y'] = $shadow; |
| 829 |
$filled['opacity'] = $shadow_opacity; |
| 830 |
} |
| 831 |
|
| 832 |
if($round) |
| 833 |
$filled['rx'] = $filled['ry'] = new Number($round); |
| 834 |
|
| 835 |
$c_el .= $this->element('rect', $filled); |
| 836 |
|
| 837 |
// increase padding to clear shadow |
| 838 |
$this->pad_right += $shadow; |
| 839 |
$this->pad_bottom += $shadow; |
| 840 |
} |
| 841 |
|
| 842 |
if($colour->opacity() < 1) |
| 843 |
$canvas['opacity'] = $colour->opacity(true); |
| 844 |
|
| 845 |
if($round) |
| 846 |
$canvas['rx'] = $canvas['ry'] = new Number($round); |
| 847 |
if($bg == '' && $stroke_width) { |
| 848 |
$canvas['stroke-width'] = $stroke_width; |
| 849 |
$canvas['stroke'] = $stroke_colour; |
| 850 |
} |
| 851 |
$c_el .= $this->element('rect', $canvas); |
| 852 |
|
| 853 |
// create a clip path for rounded rectangle |
| 854 |
if($round) { |
| 855 |
$this->defs->add($this->element('clipPath', ['id' => $id], null, |
| 856 |
$this->element('rect', $canvas))); |
| 857 |
} |
| 858 |
|
| 859 |
// if the background image is an element, insert it between the background |
| 860 |
// colour and border rect |
| 861 |
if($bg != '') { |
| 862 |
$c_el .= $bg; |
| 863 |
if($stroke_width) { |
| 864 |
$canvas['stroke-width'] = $stroke_width; |
| 865 |
$canvas['stroke'] = $stroke_colour; |
| 866 |
$canvas['fill'] = 'none'; |
| 867 |
$c_el .= $this->element('rect', $canvas); |
| 868 |
} |
| 869 |
} |
| 870 |
|
| 871 |
return $c_el; |
| 872 |
} |
| 873 |
|
| 874 |
/** |
| 875 |
* Displays readable (hopefully) error message |
| 876 |
*/ |
| 877 |
protected function errorText($error) |
| 878 |
{ |
| 879 |
if(!is_numeric($this->height)) |
| 880 |
$this->height = 100; |
| 881 |
$text = ['x' => 3, 'y' => $this->height - 3]; |
| 882 |
$style = [ |
| 883 |
'font-family' => 'Courier New', |
| 884 |
'font-size' => '11px', |
| 885 |
'font-weight' => 'bold', |
| 886 |
]; |
| 887 |
|
| 888 |
$e = $this->contrastText($text['x'], $text['y'], $error, 'blue', |
| 889 |
'white', $style); |
| 890 |
return $e; |
| 891 |
} |
| 892 |
|
| 893 |
/** |
| 894 |
* Displays high-contrast text |
| 895 |
*/ |
| 896 |
protected function contrastText($x, $y, $text, $fcolour = 'black', |
| 897 |
$bcolour = 'white', $properties = null, $styles = null) |
| 898 |
{ |
| 899 |
$xform = new Transform; |
| 900 |
$xform->translate($x, $y); |
| 901 |
$props = ['transform' => $xform, 'fill' => $fcolour]; |
| 902 |
if(is_array($properties)) |
| 903 |
$props = array_merge($properties, $props); |
| 904 |
|
| 905 |
$bg = $this->element('text', ['stroke-width' => '2px', 'stroke' => $bcolour], |
| 906 |
null, $text); |
| 907 |
$fg = $this->element('text', null, null, $text); |
| 908 |
return $this->element('g', $props, $styles, $bg . $fg); |
| 909 |
} |
| 910 |
|
| 911 |
/** |
| 912 |
* Builds an element |
| 913 |
*/ |
| 914 |
public function element($name, $attribs = null, $styles = null, |
| 915 |
$content = null, $no_whitespace = false) |
| 916 |
{ |
| 917 |
if($this->namespace && strpos($name, ':') === false) |
| 918 |
$name = 'svg:' . $name; |
| 919 |
$element = '<' . $name; |
| 920 |
if(is_array($attribs)) { |
| 921 |
foreach($attribs as $attr => $val) { |
| 922 |
$value = new Attribute($attr, $val, $this->encoding); |
| 923 |
$element .= ' ' . $attr . '="' . $value . '"'; |
| 924 |
} |
| 925 |
} |
| 926 |
|
| 927 |
if(is_array($styles)) { |
| 928 |
$element .= ' style="'; |
| 929 |
foreach($styles as $attr => $val) { |
| 930 |
$value = new Attribute($attr, $val, $this->encoding); |
| 931 |
$element .= $attr . ':' . $value . ';'; |
| 932 |
} |
| 933 |
$element .= '"'; |
| 934 |
} |
| 935 |
|
| 936 |
if($content === null) |
| 937 |
$element .= "/>"; |
| 938 |
else |
| 939 |
$element .= '>' . $content . '</' . $name . ">"; |
| 940 |
if(!$no_whitespace) |
| 941 |
$element .= "\n"; |
| 942 |
|
| 943 |
return $element; |
| 944 |
} |
| 945 |
|
| 946 |
/** |
| 947 |
* Returns a link URL or NULL if none |
| 948 |
*/ |
| 949 |
public function getLinkURL($item, $key, $row = 0) |
| 950 |
{ |
| 951 |
$link = ($item === null ? null : $item->link); |
| 952 |
if(is_numeric($key)) |
| 953 |
$key = (int)round($key); |
| 954 |
if($link === null && is_array($this->links[$row]) && |
| 955 |
isset($this->links[$row][$key])) { |
| 956 |
$link = $this->links[$row][$key]; |
| 957 |
} |
| 958 |
|
| 959 |
// check for absolute links |
| 960 |
if($link !== null && strpos($link,'//') === false) |
| 961 |
$link = $this->link_base . $link; |
| 962 |
|
| 963 |
return $link; |
| 964 |
} |
| 965 |
|
| 966 |
/** |
| 967 |
* Retrieves a link |
| 968 |
*/ |
| 969 |
public function getLink($item, $key, $content, $row = 0) |
| 970 |
{ |
| 971 |
$link = $this->getLinkURL($item, $key, $row); |
| 972 |
if($link === null) |
| 973 |
return $content; |
| 974 |
|
| 975 |
$link_attr = ['xlink:href' => $link]; |
| 976 |
if(!empty($this->link_target)) |
| 977 |
$link_attr['target'] = $this->link_target; |
| 978 |
return $this->element('a', $link_attr, null, $content); |
| 979 |
} |
| 980 |
|
| 981 |
/** |
| 982 |
* Returns TRUE if the item is visible on the graph |
| 983 |
*/ |
| 984 |
public function isVisible($item, $dataset = 0) |
| 985 |
{ |
| 986 |
// default implementation is for all non-zero values to be visible |
| 987 |
return ($item->value != 0); |
| 988 |
} |
| 989 |
|
| 990 |
/** |
| 991 |
* Sets up the colour class |
| 992 |
*/ |
| 993 |
protected function colourSetup($count, $datasets = null, $reverse = false) |
| 994 |
{ |
| 995 |
$this->colours->setup($count, $datasets, $reverse); |
| 996 |
} |
| 997 |
|
| 998 |
/** |
| 999 |
* Returns a Colour |
| 1000 |
*/ |
| 1001 |
public function getColour($item, $key, $dataset, $allow_gradient = true, |
| 1002 |
$allow_pattern = true) |
| 1003 |
{ |
| 1004 |
if($item !== null && $item->colour !== null) |
| 1005 |
return new Colour($this, $item->colour, $allow_gradient, $allow_pattern); |
| 1006 |
|
| 1007 |
$c = $this->colours->getColour($key, $dataset); |
| 1008 |
if($c === null) |
| 1009 |
return new Colour($this, null); |
| 1010 |
|
| 1011 |
$colour = new Colour($this, $c, $allow_gradient, $allow_pattern); |
| 1012 |
|
| 1013 |
// make key reflect dataset as well (for gradients) |
| 1014 |
if($dataset !== null) |
| 1015 |
$key = $dataset . ':' . $key; |
| 1016 |
if($key !== null) |
| 1017 |
$colour->setGradientKey($key); |
| 1018 |
|
| 1019 |
return $colour; |
| 1020 |
} |
| 1021 |
|
| 1022 |
/** |
| 1023 |
* Returns the first non-empty option in named argument list. |
| 1024 |
* Arguments must be "opt_name" or array("opt_name", $index), optionally |
| 1025 |
* ending with default value (non-string or array('@', $value)) |
| 1026 |
*/ |
| 1027 |
public function getOption($opt, $opt2 = null) |
| 1028 |
{ |
| 1029 |
// single option - checking for null second option is faster |
| 1030 |
// than using func_num_args() |
| 1031 |
if($opt2 === null && is_string($opt)) { |
| 1032 |
if(isset($this->settings[$opt]) && $this->settings[$opt] !== '') |
| 1033 |
return $this->settings[$opt]; |
| 1034 |
return null; |
| 1035 |
} |
| 1036 |
|
| 1037 |
$opts = func_get_args(); |
| 1038 |
foreach($opts as $opt) { |
| 1039 |
// not string or array, default value |
| 1040 |
if(!is_array($opt) && !is_string($opt)) |
| 1041 |
return $opt; |
| 1042 |
|
| 1043 |
if(is_array($opt)) { |
| 1044 |
// validate |
| 1045 |
if(!isset($opt[0]) || !isset($opt[1]) || !is_string($opt[0])) |
| 1046 |
throw new \InvalidArgumentException('Malformed option array'); |
| 1047 |
|
| 1048 |
// default value |
| 1049 |
if($opt[0] === '@') |
| 1050 |
return $opt[1]; |
| 1051 |
|
| 1052 |
list($name, $index) = $opt; |
| 1053 |
if(isset($this->settings[$name])) { |
| 1054 |
$val = $this->settings[$name]; |
| 1055 |
if(is_array($val)) { |
| 1056 |
|
| 1057 |
if(isset($val[$index])) { |
| 1058 |
$val = $val[$index]; |
| 1059 |
} else { |
| 1060 |
if(!is_numeric($index)) |
| 1061 |
$index = 0; |
| 1062 |
$val = $val[$index % count($val)]; |
| 1063 |
} |
| 1064 |
} |
| 1065 |
|
| 1066 |
if($val !== null && $val !== '') |
| 1067 |
return $val; |
| 1068 |
} |
| 1069 |
continue; |
| 1070 |
} |
| 1071 |
|
| 1072 |
// not an array |
| 1073 |
if(isset($this->settings[$opt])) { |
| 1074 |
$val = $this->settings[$opt]; |
| 1075 |
if($val !== null && $val !== '') |
| 1076 |
return $val; |
| 1077 |
} |
| 1078 |
} |
| 1079 |
|
| 1080 |
return null; |
| 1081 |
} |
| 1082 |
|
| 1083 |
/** |
| 1084 |
* Returns option from data item if present, or from settings if not |
| 1085 |
*/ |
| 1086 |
public function getItemOption($option, $dataset, &$item, $item_option = null) |
| 1087 |
{ |
| 1088 |
if($item_option === null) |
| 1089 |
$item_option = $option; |
| 1090 |
$value = null; |
| 1091 |
if($item !== null) |
| 1092 |
$value = $item->data($item_option); |
| 1093 |
if($value === null) |
| 1094 |
$value = $this->getOption([$option, $dataset]); |
| 1095 |
return $value; |
| 1096 |
} |
| 1097 |
|
| 1098 |
/** |
| 1099 |
* Option setter |
| 1100 |
*/ |
| 1101 |
public function setOption($name, $value, $index = null) |
| 1102 |
{ |
| 1103 |
// very simple for now, might have to revisit it later |
| 1104 |
if($index === null) { |
| 1105 |
$this->settings[$name] = $value; |
| 1106 |
return; |
| 1107 |
} |
| 1108 |
$this->settings[$name][$index] = $value; |
| 1109 |
} |
| 1110 |
|
| 1111 |
/** |
| 1112 |
* Returns the graph size and padding |
| 1113 |
*/ |
| 1114 |
public function getDimensions() |
| 1115 |
{ |
| 1116 |
$dimensions = [ |
| 1117 |
'width' => $this->width, |
| 1118 |
'height' => $this->height, |
| 1119 |
'pad_left' => $this->pad_left, |
| 1120 |
'pad_top' => $this->pad_top, |
| 1121 |
'pad_right' => $this->pad_right, |
| 1122 |
'pad_bottom' => $this->pad_bottom, |
| 1123 |
]; |
| 1124 |
return $dimensions; |
| 1125 |
} |
| 1126 |
|
| 1127 |
/** |
| 1128 |
* Checks that the data are valid |
| 1129 |
*/ |
| 1130 |
protected function checkValues() |
| 1131 |
{ |
| 1132 |
if($this->values->error) |
| 1133 |
throw new \Exception($this->values->error); |
| 1134 |
} |
| 1135 |
|
| 1136 |
/** |
| 1137 |
* Sets the stroke options for an element |
| 1138 |
*/ |
| 1139 |
protected function setStroke(&$attr, &$item, $key, $dataset, $line_join = null) |
| 1140 |
{ |
| 1141 |
unset($attr['stroke'], $attr['stroke-width'], $attr['stroke-linejoin'], |
| 1142 |
$attr['stroke-dasharray']); |
| 1143 |
|
| 1144 |
$stroke_width = $this->getItemOption('stroke_width', $dataset, $item); |
| 1145 |
if($stroke_width > 0) { |
| 1146 |
$cg = new ColourGroup($this, $item, $key, $dataset); |
| 1147 |
$attr['stroke'] = $cg->stroke(); |
| 1148 |
|
| 1149 |
if($attr['stroke']->opacity() < 1) |
| 1150 |
$attr['stroke-opacity'] = $attr['stroke']->opacity(true); |
| 1151 |
$attr['stroke-width'] = $stroke_width; |
| 1152 |
if($line_join !== null) |
| 1153 |
$attr['stroke-linejoin'] = $line_join; |
| 1154 |
|
| 1155 |
$dash = $this->getItemOption('stroke_dash', $dataset, $item); |
| 1156 |
if(!empty($dash)) |
| 1157 |
$attr['stroke-dasharray'] = $dash; |
| 1158 |
} |
| 1159 |
} |
| 1160 |
|
| 1161 |
/** |
| 1162 |
* Creates a new ID for an element |
| 1163 |
*/ |
| 1164 |
public function newID() |
| 1165 |
{ |
| 1166 |
$prefix = (string)$this->getOption('id_prefix'); |
| 1167 |
$i = ++Graph::$last_id; |
| 1168 |
|
| 1169 |
// id is case sensitive, so use lower and upper case |
| 1170 |
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; |
| 1171 |
$iid = []; |
| 1172 |
while($i > 0) { |
| 1173 |
$c = $i % 62; |
| 1174 |
$i = floor($i / 62); |
| 1175 |
$iid[] = $chars[$c]; |
| 1176 |
} |
| 1177 |
return $prefix . 'e' . implode(array_reverse($iid)); |
| 1178 |
} |
| 1179 |
|
| 1180 |
/** |
| 1181 |
* Adds markup to be inserted between graph and legend |
| 1182 |
*/ |
| 1183 |
public function addBackMatter($fragment) |
| 1184 |
{ |
| 1185 |
$this->back_matter .= $fragment; |
| 1186 |
} |
| 1187 |
|
| 1188 |
/** |
| 1189 |
* Adds context menu for item |
| 1190 |
*/ |
| 1191 |
protected function setContextMenu(&$element, $dataset, &$item, |
| 1192 |
$duplicate = false) |
| 1193 |
{ |
| 1194 |
if($this->context_menu === null) { |
| 1195 |
$js = $this->getJavascript(); |
| 1196 |
$this->context_menu = new ContextMenu($this, $js); |
| 1197 |
} |
| 1198 |
$this->context_menu->setMenu($element, $dataset, $item, $duplicate); |
| 1199 |
} |
| 1200 |
|
| 1201 |
/** |
| 1202 |
* Default tooltip contents are key and value, or whatever |
| 1203 |
* $key is if $value is not set |
| 1204 |
*/ |
| 1205 |
protected function setTooltip(&$element, &$item, $dataset, $key, $value = null, |
| 1206 |
$duplicate = false) |
| 1207 |
{ |
| 1208 |
$callback = $this->getOption('tooltip_callback'); |
| 1209 |
$structure = $this->getOption('structure'); |
| 1210 |
if(is_callable($callback)) { |
| 1211 |
if($value === null) |
| 1212 |
$value = $key; |
| 1213 |
$text = call_user_func_array($callback, [$dataset, $key, $value]); |
| 1214 |
} elseif(is_array($structure) && isset($structure['tooltip'])) { |
| 1215 |
// use structured data tooltips if specified |
| 1216 |
$text = $item->tooltip; |
| 1217 |
} else { |
| 1218 |
$text = $this->formatTooltip($item, $dataset, $key, $value); |
| 1219 |
} |
| 1220 |
if($text === null) |
| 1221 |
return; |
| 1222 |
$text = addslashes(str_replace("\n", '\n', $text)); |
| 1223 |
return $this->getJavascript()->setTooltip($element, $text, $duplicate); |
| 1224 |
} |
| 1225 |
|
| 1226 |
/** |
| 1227 |
* Default format is value only |
| 1228 |
*/ |
| 1229 |
protected function formatTooltip(&$item, $dataset, $key, $value) |
| 1230 |
{ |
| 1231 |
$n = new Number($value, $this->getOption('units_tooltip'), |
| 1232 |
$this->getOption('units_before_tooltip')); |
| 1233 |
return $n->format(); |
| 1234 |
} |
| 1235 |
|
| 1236 |
/** |
| 1237 |
* Adds a data label to the list |
| 1238 |
*/ |
| 1239 |
protected function addDataLabel($dataset, $index, &$element, &$item, |
| 1240 |
$x, $y, $w, $h, $content = null, $duplicate = true) |
| 1241 |
{ |
| 1242 |
if(!$this->getOption(['show_data_labels', $dataset])) |
| 1243 |
return false; |
| 1244 |
|
| 1245 |
// set up fading for this label? |
| 1246 |
$id = null; |
| 1247 |
$fade_in = $this->getOption(['data_label_fade_in_speed', $dataset]); |
| 1248 |
$fade_out = $this->getOption(['data_label_fade_out_speed', $dataset]); |
| 1249 |
$click = $this->getOption(['data_label_click', $dataset]); |
| 1250 |
$popup = $this->getOption(['data_label_popfront', $dataset]); |
| 1251 |
if($click == 'hide' || $click == 'show') { |
| 1252 |
$id = $this->newID(); |
| 1253 |
$this->getJavascript()->setClickShow($element, $id, $click == 'hide', $duplicate); |
| 1254 |
} |
| 1255 |
if($popup) { |
| 1256 |
if(!$id) |
| 1257 |
$id = $this->newID(); |
| 1258 |
$this->getJavascript()->setPopFront($element, $id, $duplicate); |
| 1259 |
} |
| 1260 |
if($fade_in || $fade_out) { |
| 1261 |
$speed_in = $fade_in ? $fade_in / 100 : 0; |
| 1262 |
$speed_out = $fade_out ? $fade_out / 100 : 0; |
| 1263 |
if(!$id) |
| 1264 |
$id = $this->newID(); |
| 1265 |
$this->getJavascript()->setFader($element, $speed_in, $speed_out, $id, $duplicate); |
| 1266 |
} |
| 1267 |
$this->getDataLabels()->addLabel($dataset, $index, $item, $x, $y, $w, $h, $id, |
| 1268 |
$content, $fade_in, $click); |
| 1269 |
return true; |
| 1270 |
} |
| 1271 |
|
| 1272 |
/** |
| 1273 |
* Adds an element as a client of existing label |
| 1274 |
*/ |
| 1275 |
protected function addLabelClient($dataset, $index, &$element) |
| 1276 |
{ |
| 1277 |
$label = $this->getDataLabels()->getLabel($dataset, $index); |
| 1278 |
if($label === null) |
| 1279 |
return false; |
| 1280 |
|
| 1281 |
$id = $label['id']; |
| 1282 |
$fade_in = $this->getOption(['data_label_fade_in_speed', $dataset]); |
| 1283 |
$fade_out = $this->getOption(['data_label_fade_out_speed', $dataset]); |
| 1284 |
$click = $this->getOption(['data_label_click', $dataset]); |
| 1285 |
$popup = $this->getOption(['data_label_popfront', $dataset]); |
| 1286 |
if($click == 'hide' || $click == 'show') |
| 1287 |
$this->getJavascript()->setClickShow($element, $id, $click == 'hide', true); |
| 1288 |
if($popup) |
| 1289 |
$this->getJavascript()->setPopFront($element, $id, true); |
| 1290 |
if($fade_in || $fade_out) { |
| 1291 |
$speed_in = $fade_in ? $fade_in / 100 : 0; |
| 1292 |
$speed_out = $fade_out ? $fade_out / 100 : 0; |
| 1293 |
$this->getJavascript()->setFader($element, $speed_in, $speed_out, $id, true); |
| 1294 |
} |
| 1295 |
} |
| 1296 |
|
| 1297 |
/** |
| 1298 |
* Adds a label for non-data text |
| 1299 |
*/ |
| 1300 |
protected function addContentLabel($dataset, $index, $x, $y, $w, $h, $content) |
| 1301 |
{ |
| 1302 |
$this->getDataLabels()->addContentLabel($dataset, $index, $x, $y, $w, $h, |
| 1303 |
$content); |
| 1304 |
return true; |
| 1305 |
} |
| 1306 |
|
| 1307 |
/** |
| 1308 |
* Draws the data labels |
| 1309 |
*/ |
| 1310 |
protected function drawDataLabels() |
| 1311 |
{ |
| 1312 |
if(isset($this->settings['label'])) |
| 1313 |
$this->getDataLabels()->load($this->settings); |
| 1314 |
return $this->getDataLabels()->getLabels(); |
| 1315 |
} |
| 1316 |
|
| 1317 |
/** |
| 1318 |
* Returns the position for a data label |
| 1319 |
*/ |
| 1320 |
public function dataLabelPosition($dataset, $index, &$item, $x, $y, $w, $h, |
| 1321 |
$label_w, $label_h) |
| 1322 |
{ |
| 1323 |
$pos = $this->getOption(['data_label_position', $dataset]); |
| 1324 |
if(empty($pos)) |
| 1325 |
$pos = 'above'; |
| 1326 |
$end = [$x + $w * 0.5, $y + $h * 0.5]; |
| 1327 |
return [$pos, $end]; |
| 1328 |
} |
| 1329 |
|
| 1330 |
/** |
| 1331 |
* Returns the ShapeList instance |
| 1332 |
*/ |
| 1333 |
public function getShapeList() |
| 1334 |
{ |
| 1335 |
if($this->shapes === null) { |
| 1336 |
$this->shapes = new ShapeList($this); |
| 1337 |
$this->shapes->load($this->settings); |
| 1338 |
} |
| 1339 |
return $this->shapes; |
| 1340 |
} |
| 1341 |
|
| 1342 |
public function underShapes() |
| 1343 |
{ |
| 1344 |
if(!isset($this->settings['shape'])) |
| 1345 |
return ''; |
| 1346 |
return $this->getShapeList()->draw(ShapeList::BELOW); |
| 1347 |
} |
| 1348 |
|
| 1349 |
public function overShapes() |
| 1350 |
{ |
| 1351 |
if(!isset($this->settings['shape'])) |
| 1352 |
return ''; |
| 1353 |
return $this->getShapeList()->draw(ShapeList::ABOVE); |
| 1354 |
} |
| 1355 |
|
| 1356 |
/** |
| 1357 |
* Returns TRUE if the position is inside the item |
| 1358 |
*/ |
| 1359 |
public static function isPositionInside($pos) |
| 1360 |
{ |
| 1361 |
list($hpos, $vpos) = Graph::translatePosition($pos); |
| 1362 |
return strpos($hpos . $vpos, 'o') === false; |
| 1363 |
} |
| 1364 |
|
| 1365 |
/** |
| 1366 |
* Sets the styles for data labels |
| 1367 |
*/ |
| 1368 |
public function dataLabelStyle($dataset, $index, &$item) |
| 1369 |
{ |
| 1370 |
// this function gets called a lot, so cache the return values |
| 1371 |
if(isset($this->data_label_style_cache[$dataset])) |
| 1372 |
return $this->data_label_style_cache[$dataset]; |
| 1373 |
|
| 1374 |
$map = $this->getDataLabels()->getStyleMap(); |
| 1375 |
$style = []; |
| 1376 |
foreach($map as $key => $option) { |
| 1377 |
$style[$key] = $this->getOption([$option, $dataset]); |
| 1378 |
} |
| 1379 |
|
| 1380 |
// padding x/y options override single value |
| 1381 |
$style['pad_x'] = $this->getOption(['data_label_padding_x', $dataset], |
| 1382 |
['data_label_padding', $dataset]); |
| 1383 |
$style['pad_y'] = $this->getOption(['data_label_padding_y', $dataset], |
| 1384 |
['data_label_padding', $dataset]); |
| 1385 |
|
| 1386 |
$this->data_label_style_cache[$dataset] = $style; |
| 1387 |
return $style; |
| 1388 |
} |
| 1389 |
|
| 1390 |
/** |
| 1391 |
* Tail direction is required for some types of label |
| 1392 |
*/ |
| 1393 |
public function dataLabelTailDirection($dataset, $index, $hpos, $vpos) |
| 1394 |
{ |
| 1395 |
// angle starts at right, goes clockwise |
| 1396 |
$angle = 90; |
| 1397 |
$pos = str_replace(['i', 'o', 'm'], '', $vpos) . |
| 1398 |
str_replace(['i', 'o', 'c'], '', $hpos); |
| 1399 |
switch($pos) { |
| 1400 |
case 'l' : $angle = 0; break; |
| 1401 |
case 'tl' : $angle = 45; break; |
| 1402 |
case 't' : $angle = 90; break; |
| 1403 |
case 'tr' : $angle = 135; break; |
| 1404 |
case 'r' : $angle = 180; break; |
| 1405 |
case 'br' : $angle = 225; break; |
| 1406 |
case 'b' : $angle = 270; break; |
| 1407 |
case 'bl' : $angle = 315; break; |
| 1408 |
} |
| 1409 |
return $angle; |
| 1410 |
} |
| 1411 |
|
| 1412 |
/** |
| 1413 |
* Builds and returns the body of the graph |
| 1414 |
*/ |
| 1415 |
private function buildGraph() |
| 1416 |
{ |
| 1417 |
$this->checkValues($this->values); |
| 1418 |
|
| 1419 |
// body content comes from the subclass |
| 1420 |
return $this->drawGraph(); |
| 1421 |
} |
| 1422 |
|
| 1423 |
/** |
| 1424 |
* Returns the SVG document |
| 1425 |
*/ |
| 1426 |
public function fetch($header = true, $defer_javascript = true) |
| 1427 |
{ |
| 1428 |
$content = ''; |
| 1429 |
if($header) { |
| 1430 |
$content .= '<?xml version="1.0"'; |
| 1431 |
// encoding comes before standalone |
| 1432 |
if(strlen($this->encoding) > 0) |
| 1433 |
$content .= ' encoding="' . $this->encoding . '"'; |
| 1434 |
$content .= ' standalone="no"?' . ">\n"; |
| 1435 |
if($this->getOption('doctype')) |
| 1436 |
$content .= '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ' . |
| 1437 |
'"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">' . "\n"; |
| 1438 |
} |
| 1439 |
|
| 1440 |
// set the precision - PHP default is 14 digits! |
| 1441 |
$old_precision = ini_set('precision', $this->settings['precision']); |
| 1442 |
Number::setup($this->settings['precision'], $this->settings['decimal'], |
| 1443 |
$this->settings['thousands']); |
| 1444 |
|
| 1445 |
$heading = $foot = ''; |
| 1446 |
// display title and description if available |
| 1447 |
if($this->getOption('title')) |
| 1448 |
$heading .= $this->element('title', null, null, $this->getOption('title')); |
| 1449 |
if($this->getOption('description')) |
| 1450 |
$heading .= $this->element('desc', null, null, $this->getOption('description')); |
| 1451 |
|
| 1452 |
try { |
| 1453 |
$body = $this->buildGraph(); |
| 1454 |
} catch(\Exception $e) { |
| 1455 |
if($this->getOption('exception_throw')) |
| 1456 |
throw $e; |
| 1457 |
|
| 1458 |
$err = $e->getMessage(); |
| 1459 |
$details = $this->getOption('exception_details'); |
| 1460 |
if($details) |
| 1461 |
$err .= ' [' . basename($e->getFile()) . ' #' . $e->getLine() . ']'; |
| 1462 |
$body = $this->errorText($err); |
| 1463 |
|
| 1464 |
if($details) { |
| 1465 |
$body .= "<!--\nException thrown from " . |
| 1466 |
$e->getFile() . " @ " . $e->getLine() . "\nTrace: \n" . |
| 1467 |
$e->getTraceAsString() . "\n-->\n"; |
| 1468 |
} |
| 1469 |
} |
| 1470 |
|
| 1471 |
$svg = [ |
| 1472 |
'version' => '1.1', |
| 1473 |
'width' => new Number($this->width), |
| 1474 |
'height' => new Number($this->height), |
| 1475 |
]; |
| 1476 |
|
| 1477 |
if($this->subgraph) { |
| 1478 |
// subgraphs need x and y, and can overflow |
| 1479 |
$x = $this->getOption('graph_x'); |
| 1480 |
$y = $this->getOption('graph_y'); |
| 1481 |
if($x) |
| 1482 |
$svg['x'] = $x; |
| 1483 |
if($y) |
| 1484 |
$svg['y'] = $y; |
| 1485 |
$svg['overflow'] = 'visible'; |
| 1486 |
} else { |
| 1487 |
if($this->namespace) |
| 1488 |
$svg['xmlns:svg'] = 'http://www.w3.org/2000/svg'; |
| 1489 |
else |
| 1490 |
$svg['xmlns'] = 'http://www.w3.org/2000/svg'; |
| 1491 |
$svg['xmlns:xlink'] = 'http://www.w3.org/1999/xlink'; |
| 1492 |
|
| 1493 |
// add any extra namespaces |
| 1494 |
foreach($this->namespaces as $ns => $url) |
| 1495 |
$svg['xmlns:' . $ns] = $url; |
| 1496 |
|
| 1497 |
if($this->getOption('auto_fit')) { |
| 1498 |
// convert pixel size to viewbox size |
| 1499 |
$svg['viewBox'] = '0 0 ' . $svg['width'] . ' ' . $svg['height']; |
| 1500 |
$svg['width'] = $svg['height'] = '100%'; |
| 1501 |
} |
| 1502 |
} |
| 1503 |
if($this->getOption('svg_class')) |
| 1504 |
$svg['class'] = $this->getOption('svg_class'); |
| 1505 |
|
| 1506 |
if(!$defer_javascript) |
| 1507 |
$foot .= $this->fetchJavascript(true, !$this->namespace); |
| 1508 |
|
| 1509 |
// add defs to heading |
| 1510 |
$heading .= $this->defs->get(); |
| 1511 |
|
| 1512 |
// display version string |
| 1513 |
if($this->getOption('show_version')) { |
| 1514 |
$text = ['x' => $this->pad_left, 'y' => $this->height - 3]; |
| 1515 |
$style = [ |
| 1516 |
'font-family' => 'Courier New', |
| 1517 |
'font-size' => '12px', |
| 1518 |
'font-weight' => 'bold', |
| 1519 |
]; |
| 1520 |
$body .= $this->contrastText($text['x'], $text['y'], SVGGraph::VERSION, |
| 1521 |
'blue', 'white', $style); |
| 1522 |
} |
| 1523 |
|
| 1524 |
$content .= $this->element('svg', $svg, null, $heading . $body . $foot); |
| 1525 |
// replace PHP's precision |
| 1526 |
ini_set('precision', $old_precision); |
| 1527 |
|
| 1528 |
if($this->getOption('minify')) |
| 1529 |
$content = preg_replace('/\>\s+\</', '><', $content); |
| 1530 |
return $content; |
| 1531 |
} |
| 1532 |
|
| 1533 |
/** |
| 1534 |
* Renders the SVG document |
| 1535 |
*/ |
| 1536 |
public function render($header = true, $content_type = true, |
| 1537 |
$defer_javascript = false) |
| 1538 |
{ |
| 1539 |
$mime_header = 'Content-type: image/svg+xml; charset=UTF-8'; |
| 1540 |
if($content_type) |
| 1541 |
header($mime_header); |
| 1542 |
|
| 1543 |
try { |
| 1544 |
echo $this->fetch($header, $defer_javascript); |
| 1545 |
} catch(\Exception $e) { |
| 1546 |
if($this->getOption('exception_throw')) |
| 1547 |
throw $e; |
| 1548 |
$this->errorText($e); |
| 1549 |
} |
| 1550 |
} |
| 1551 |
|
| 1552 |
/** |
| 1553 |
* When using the defer_javascript option, this returns the |
| 1554 |
* Javascript block |
| 1555 |
*/ |
| 1556 |
public function fetchJavascript($cdata = true, $no_namespace = true) |
| 1557 |
{ |
| 1558 |
if(!isset(Graph::$javascript)) |
| 1559 |
return ''; |
| 1560 |
|
| 1561 |
$script = Graph::$javascript->getCode($cdata, $this->getOption('minify_js')); |
| 1562 |
if($script == '') |
| 1563 |
return ''; |
| 1564 |
|
| 1565 |
$script_attr = ['type' => 'application/ecmascript']; |
| 1566 |
$namespace = $this->namespace; |
| 1567 |
if($no_namespace) |
| 1568 |
$this->namespace = false; |
| 1569 |
$js = $this->element('script', $script_attr, null, $script); |
| 1570 |
if($no_namespace) |
| 1571 |
$this->namespace = $namespace; |
| 1572 |
return $js; |
| 1573 |
} |
| 1574 |
|
| 1575 |
/** |
| 1576 |
* Returns the minimum value in the array, ignoring NULLs |
| 1577 |
*/ |
| 1578 |
public static function min(&$a) |
| 1579 |
{ |
| 1580 |
$min = null; |
| 1581 |
foreach($a as $v) { |
| 1582 |
if($v !== null && ($min === null || $v < $min)) |
| 1583 |
$min = $v; |
| 1584 |
} |
| 1585 |
return $min; |
| 1586 |
} |
| 1587 |
|
| 1588 |
/** |
| 1589 |
* Converts a string key to a unix timestamp, or NULL if invalid |
| 1590 |
*/ |
| 1591 |
public static function dateConvert($k) |
| 1592 |
{ |
| 1593 |
// date_create functions return false if the conversion fails |
| 1594 |
$dt = false; |
| 1595 |
|
| 1596 |
// if the format is set, try it |
| 1597 |
if(Graph::$key_format !== null) |
| 1598 |
$dt = date_create_from_format(Graph::$key_format, $k); |
| 1599 |
|
| 1600 |
// try default conversion |
| 1601 |
if($dt === false) |
| 1602 |
$dt = date_create($k); |
| 1603 |
|
| 1604 |
// give up |
| 1605 |
if($dt === false) |
| 1606 |
return null; |
| 1607 |
|
| 1608 |
// this works in 64-bit on 32-bit systems, getTimestamp() doesn't |
| 1609 |
return $dt->format('U'); |
| 1610 |
} |
| 1611 |
} |
| 1612 |
|