. */ /** * For more information, please contact */ namespace Goat1000\SVGGraph; /** * Base class for all graph types */ abstract class Graph { public $subgraph = false; protected $width = 0; protected $height = 0; protected $pad_left = 0; protected $pad_right = 0; protected $pad_top = 0; protected $pad_bottom = 0; protected $settings = []; protected $values = []; protected $namespace = false; protected $link_base = ''; protected $link_target = '_blank'; protected $links = []; public $encoding = 'UTF-8'; protected $colours = null; public $defs = null; public $figures = null; protected $subgraphs = []; protected $back_matter = ''; protected $namespaces = []; private static $last_id = 0; public static $key_format = null; protected $legend = null; protected $data_label_style_cache = []; protected $multi_graph; private static $javascript = null; private $data_labels = null; private $context_menu = null; private $shapes = null; /** * @arg $w = width * @arg $h = height * @arg $settings = user options * @arg $fixed_settings = class options overriding user options */ public function __construct($w, $h, array $settings, array $fixed_settings = []) { $this->width = $w; $this->height = $h; $this->defs = new Defs($this); // get settings from ini file that are relevant to this class $class = get_class($this); $ini_settings = $this->ini_settings($class); // default option overrides - subclasses can override these $fixed_setting_defaults = [ 'repeated_keys' => 'error', 'sort_keys' => true, 'require_structured' => false, 'require_integer_keys' => true, ]; $this->settings = array_merge($this->settings, $ini_settings, $settings, $fixed_setting_defaults, $fixed_settings); // set up figures - it can't be dynamic because figures can refer // to other figures via Markers $this->figures = new Figures($this, $this->settings); // copy some settings to member variables $opts = ['namespace', 'link_base', 'link_target', 'encoding', 'pad_top', 'pad_bottom', 'pad_left', 'pad_right']; foreach($opts as $opt) $this->{$opt} = $this->getOption($opt); } /** * Deprecated option/member access - display error and fail */ public function __get($name) { trigger_error('Attempt to get $this->' . $name, E_USER_WARNING); debug_print_backtrace(0, 1); exit; } public function __isset($name) { trigger_error('Isset attempt on option $this->' . $name, E_USER_WARNING); debug_print_backtrace(0, 1); exit; } public function __set($name, $value) { trigger_error('Attempt to set $this->' . $name, E_USER_WARNING); debug_print_backtrace(0, 1); exit; } /** * Returns the settings from the ini file for a class */ protected function ini_settings($class) { $ini_file = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'svggraph.ini'; $ini_settings = false; if(file_exists($ini_file)) $ini_settings = parse_ini_file($ini_file, true); if($ini_settings === false) throw new \Exception('INI file [' . $ini_file . '] could not be loaded'); $hierarchy = [$class]; while($class = get_parent_class($class)) array_unshift($hierarchy, $class); $settings = []; while(count($hierarchy)) { $class = array_shift($hierarchy); $ns = strrpos($class, '\\'); $class = substr($class, $ns + 1); if(array_key_exists($class, $ini_settings)) $settings = array_merge($settings, $ini_settings[$class]); } return $settings; } /** * Sets the graph values */ public function values($values) { $new_values = []; $v = func_get_args(); if(count($v) == 1) $v = array_shift($v); $set_values = true; if(is_array($v)) { reset($v); $first_key = key($v); if($first_key !== null && is_array($v[$first_key])) { foreach($v as $data_set) $new_values[] = $data_set; $set_values = false; } } if($set_values) $new_values[] = $v; $require_structured = $this->getOption('require_structured'); $structured_data = $this->getOption('structured_data'); $structure = $this->getOption('structure'); $datetime_keys = $this->getOption('datetime_keys'); $datetime_key_format = $this->getOption('datetime_key_format'); $force_assoc = $this->getOption('force_assoc'); if($this->getOption('scatter_2d')) { $this->setOption('scatter_2d', false); if(empty($structure)) { $structure = ['key' => 0, 'value' => 1, 'datasets' => true]; $this->setOption('structure', $structure); } } if($datetime_keys && $datetime_key_format) Graph::$key_format = $datetime_key_format; if($structured_data || is_array($structure)) { $this->setOption('structured_data', true); $this->values = new StructuredData($new_values, $force_assoc, $datetime_keys, $structure, $this->getOption('repeated_keys'), $this->getOption('sort_keys'), $this->getOption('require_integer_keys'), $require_structured); } else { $this->values = new Data($new_values, $force_assoc, $datetime_keys); if(!$this->values->error && !empty($require_structured)) $this->values->error = get_class($this) . ' requires structured data'; } if($this->values->error) return; $dataset = $this->getOption('dataset', 0); if($dataset === 0) return; $dcount = count($this->values); $dataset = $this->getOption(['dataset', 0], 0); if($dcount <= $dataset) { $this->values->error = 'No valid datasets selected'; return; } // dataset option doesn't work well without structured data $this->values = StructuredData::convertFrom($this->values, $force_assoc, $datetime_keys, $this->getOption('require_integer_keys')); } /** * Sets the links from each item */ public function links() { $this->links = func_get_args(); } /** * Assigns the list of subgraphs */ public function subgraphs($subgraphs) { $this->subgraphs = $subgraphs; } /** * Set up the colours */ public function colours(Colours $colours) { $this->colours = $colours; } public function getMinValue() { $d = $this->getOption(['dataset', 0], 0); return $this->values->getMinValue($d); } public function getMaxValue() { $d = $this->getOption(['dataset', 0], 0); return $this->values->getMaxValue($d); } public function getMinKey() { $d = $this->getOption(['dataset', 0], 0); return $this->values->getMinKey($d); } public function getMaxKey() { $d = $this->getOption(['dataset', 0], 0); return $this->values->getMaxKey($d); } public function getKey($i) { return $this->values->getKey($i); } /** * Sets up the colours used for the graph */ protected function setup() { $dataset = $this->getOption(['dataset', 0], 0); $this->colourSetup($this->values->itemsCount($dataset)); } /** * Returns the Javascript instance */ public function getJavascript() { if(!isset(Graph::$javascript)) Graph::$javascript = new Javascript($this); return Graph::$javascript; } /** * Returns the DataLabels instance */ public function getDataLabels() { if($this->data_labels === null) $this->data_labels = new DataLabels($this); return $this->data_labels; } /** * Draws the selected graph */ public function drawGraph() { $canvas_id = $this->newID(); $this->initLegend(); $this->setup(); if(!is_numeric($this->width)) $this->width = 640; if(!is_numeric($this->height)) $this->height = 480; $contents = $this->canvas($canvas_id); $contents .= $this->drawTitle(); $contents .= $this->draw(); $contents .= $this->drawDataLabels(); $contents .= $this->drawBackMatter(); $contents .= $this->drawLegend(); foreach($this->subgraphs as $subgraph) $contents .= $subgraph->fetch($this); // magnifying means everything must be in a group for transformation if(!$this->subgraph && $this->getOption('magnify')) { $this->getJavascript()->magnifier(); $group = ['class' => 'svggraph-magnifier']; $contents = $this->element('g', $group, null, $contents); } // rounded rects might need a clip path if($this->getOption('back_round') && $this->getOption('back_round_clip')) { $group = ['clip-path' => 'url(#' . $canvas_id . ')']; return $this->element('g', $group, null, $contents); } return $contents; } /** * Adds any markup that goes after the graph */ protected function drawBackMatter() { return $this->back_matter; } /** * Sets up the legend class */ protected function initLegend() { $this->legend = null; // see if the legend is needed if(!$this->getOption('show_legend')) return; $entries = $this->getOption('legend_entries'); $structure = $this->getOption('structure'); if(empty($entries) && !isset($structure['legend_text'])) return; $this->legend = new Legend($this); } /** * Returns the ordering for legend entries */ public function getLegendOrder() { // null for no special order return null; } /** * Draws the legend */ protected function drawLegend() { if($this->legend === null) return ''; return $this->legend->draw(); } /** * Parses a position string, returning x and y coordinates */ public function parsePosition($pos, $w = 0, $h = 0, $pad = 0) { $inner = true; $parts = preg_split('/\s+/', $pos); if(count($parts)) { // if 'outer' is found after 'inner', it takes precedence $parts = array_reverse($parts); $inner_at = array_search('inner', $parts); $outer_at = array_search('outer', $parts); if($outer_at !== false && ($inner_at === false || $inner_at < $outer_at)) $inner = false; } if($inner) { $t = $this->pad_top; $l = $this->pad_left; $b = $this->height - $this->pad_bottom; $r = $this->width - $this->pad_right; // make sure it fits to keep RelativePosition happy if($w > $r - $l) $w = $r - $l; if($h > $b - $t) $h = $b - $t; } else { $t = $l = 0; $b = $this->height; $r = $this->width; } // ParsePosition is always inside canvas or graph, defaulted to top left $pos = 'top left ' . str_replace('outer', 'inner', $pos); return Graph::relativePosition($pos, $t, $l, $b, $r, $w, $h, $pad); } /** * Returns [hpos,vpos,offset_x,offset_y] positions derived from full * position string */ public static function translatePosition($pos) { $parts = preg_split('/\s+/', strtolower($pos)); $offset_x = $offset_y = 0; $inside = true; $vpos = 'm'; $hpos = 'c'; // translated positions: // ot, t, m, b, ob = outside top, top, middle, bottom, outside bottom // ol, l, c, r, or = outside left, left, centre, right, outside right while(count($parts)) { $part = array_shift($parts); switch($part) { case 'outer' : case 'outside' : $inside = false; break; case 'inner' : case 'inside' : $inside = true; break; case 'top' : $vpos = $inside ? 't' : 'ot'; break; case 'bottom' : $vpos = $inside ? 'b' : 'ob'; break; case 'left' : $hpos = $inside ? 'l' : 'ol'; break; case 'right' : $hpos = $inside ? 'r' : 'or'; break; case 'above' : $inside = false; $vpos = 'ot'; break; case 'below' : $inside = false; $vpos = 'ob'; break; case 'center' : $hpos = $inside ? 'c' : 'oc'; break; case 'middle' : $vpos = $inside ? 'm' : 'om'; break; default: if(is_numeric($part)) { $offset_x = $part; if(count($parts) && is_numeric($parts[0])) $offset_y = array_shift($parts); } } } return [$hpos, $vpos, $offset_x, $offset_y]; } /** * Returns [x,y,text-anchor,hpos,vpos] position that is $pos relative to the * top, left, bottom and right. * When $text is true, x and y are adjusted for text-anchor position */ public static function relativePosition($pos, $top, $left, $bottom, $right, $width, $height, $pad, $text = false) { list($hpos, $vpos, $offset_x, $offset_y) = Graph::translatePosition($pos); // if the containers have no thickness, position outside $translate = ['l' => 'ol', 'r' => 'or', 't' => 'ot', 'b' => 'ob', 'c' => 'oc', 'm' => 'om']; if($top == $bottom && isset($translate[$vpos])) $vpos = $translate[$vpos]; if($left == $right && isset($translate[$hpos])) $hpos = $translate[$hpos]; switch($vpos) { case 'ot' : $y = $top - $height - $pad; break; case 't' : $y = $top + $pad; break; case 'b' : $y = $bottom - $height - $pad; break; case 'ob' : $y = $bottom + $pad; break; case 'om': $y = $top + ($bottom - $top - $height) / 2; break; case 'm' : default : $y = $top + ($bottom - $top - $height) / 2; break; } if(($hpos == 'r' || $hpos == 'l') && $right - $left - $pad - $width < 0) $hpos = 'c'; switch($hpos) { case 'ol' : $x = $left - $width - $pad; break; case 'l' : $x = $left + $pad; break; case 'r' : $x = $right - $width - $pad; break; case 'or' : $x = $right + $pad; break; case 'oc' : $x = $left + ($right - $left - $width - $pad) / 2; break; case 'c' : default : $x = $left + ($right - $left - $width) / 2; break; } $y += $offset_y; $x += $offset_x; // third return value is text alignment $align_map = [ 'ol' => 'end', 'l' => 'start', 'c' => 'middle', 'r' => 'end', 'or' => 'start' ]; $text_align = $align_map[$hpos]; // in text mode, adjust X for text alignment if($text && $hpos != 'l' && $hpos != 'or') { if($hpos == 'c') $x += $width / 2; else $x += $width; } return [$x, $y, $text_align, $hpos, $vpos]; } /** * Sets the style info for the legend */ protected function setLegendEntry($dataset, $index, $item, $style_info) { if($this->legend === null) return; $this->legend->setEntry($dataset, $index, $item, $style_info); } /** * Subclasses must draw the entry, if they can */ protected function drawLegendEntry($x, $y, $w, $h, $entry) { return ''; } /** * Returns details of title and subtitle */ protected function getTitle() { $info = [ 'title' => $this->getOption('graph_title'), 'font' => $this->getOption('graph_title_font'), 'font_size' => 0, // 0 font size = no title 'sfont_size' => 0, // 0 = no subtitle ]; $svg_text = new Text($this, $info['font']); if($svg_text->strlen($info['title']) <= 0) return $info; $info['font_size'] = Number::units($this->getOption('graph_title_font_size')); $info['weight'] = $this->getOption('graph_title_font_weight'); $info['colour'] = $this->getOption('graph_title_colour'); $info['pos'] = $this->getOption('graph_title_position'); $info['space'] = $this->getOption('graph_title_space'); $info['line_spacing'] = Number::units($this->getOption('graph_title_line_spacing')); if($info['line_spacing'] === null || $info['line_spacing'] < 1) $info['line_spacing'] = $info['font_size']; if($info['pos'] != 'bottom' && $info['pos'] != 'left' && $info['pos'] != 'right') $info['pos'] = 'top'; list($width, $height) = $svg_text->measure($info['title'], $info['font_size'], 0, $info['line_spacing']); $info['width'] = $width; $info['height'] = $height; // now deal with sub-title $info['stitle'] = $this->getOption('graph_subtitle'); $info['sfont'] = $this->getOption('graph_subtitle_font', 'graph_title_font'); $svg_subtext = new Text($this, $info['sfont']); if($svg_subtext->strlen($info['stitle']) > 0) { $info['sfont_size'] = Number::units($this->getOption('graph_subtitle_font_size', 'graph_title_font_size')); $info['sweight'] = $this->getOption('graph_subtitle_font_weight', 'graph_title_font_weight'); $info['scolour'] = $this->getOption('graph_subtitle_colour', 'graph_title_colour'); $info['sspace'] = $this->getOption('graph_subtitle_space', 'graph_title_space'); $info['sline_spacing'] = Number::units($this->getOption('graph_subtitle_line_spacing')); if($info['sline_spacing'] === null || $info['sline_spacing'] < 1) $info['sline_spacing'] = $info['sfont_size']; list($swidth, $sheight) = $svg_subtext->measure($info['stitle'], $info['sfont_size'], 0, $info['sline_spacing']); $info['swidth'] = $swidth; $info['sheight'] = $sheight; } return $info; } /** * Draws the graph title, if there is one */ protected function drawTitle() { $info = $this->getTitle(); if($info['font_size'] == 0) return ''; $svg_text = new Text($this, $info['font']); $baseline = $svg_text->baseline($info['font_size']); $text = [ 'font-size' => $info['font_size'], 'font-family' => $info['font'], 'font-weight' => $info['weight'], 'text-anchor' => 'middle', 'fill' => new Colour($this, $info['colour']), ]; // ensure outside padding is at least the title space $pad_side = 'pad_' . $info['pos']; if($this->{$pad_side} < $info['space']) $this->{$pad_side} = $info['space']; $xform = new Transform; switch($info['pos']) { case 'left': $text['x'] = $this->pad_left + $baseline; $text['y'] = $this->height / 2; $xform->rotate(270, $text['x'], $text['y']); $text['transform'] = $xform; break; case 'right': $text['x'] = $this->width - $this->pad_right - $baseline; $text['y'] = $this->height / 2; $xform->rotate(90, $text['x'], $text['y']); $text['transform'] = $xform; break; case 'bottom': $text['x'] = $this->width / 2; $text['y'] = $this->height - $this->pad_bottom - $info['height'] + $baseline; break; default: $text['x'] = $this->width / 2; $text['y'] = $this->pad_top + $baseline; } // increase padding by size of text $this->{$pad_side} += $info['height'] + $info['space']; // now deal with sub-title $subtitle_text = ''; if($info['sfont_size'] != 0) { $svg_subtext = new Text($this, $info['sfont']); $sbaseline = $svg_subtext->baseline($info['sfont_size']); $stext = [ 'font-size' => $info['sfont_size'], 'font-family' => $info['sfont'], 'font-weight' => $info['sweight'], 'text-anchor' => 'middle', 'fill' => new Colour($this, $info['scolour']), ]; $sxform = new Transform; $sub_offset = $sbaseline + $info['sspace'] - $info['space']; switch($info['pos']) { case 'left': $stext['x'] = $this->pad_left + $sub_offset; $stext['y'] = $text['y']; $sxform->rotate(270, $stext['x'], $stext['y']); $stext['transform'] = $sxform; break; case 'right': $stext['x'] = $this->width - $this->pad_right - $sub_offset; $stext['y'] = $text['y']; $sxform->rotate(90, $stext['x'], $stext['y']); $stext['transform'] = $sxform; break; case 'bottom': // complicates things - need to shift title up $stext['x'] = $text['x']; $stext['y'] = $text['y'] - $baseline + $info['height'] + $sbaseline - $info['sheight']; $text['y'] -= $info['sheight'] + $info['sspace']; break; default: $stext['x'] = $text['x']; $stext['y'] = $this->pad_top + $sub_offset; } $this->{$pad_side} += $info['sheight'] + $info['sspace']; $subtitle_text = $svg_subtext->text($info['stitle'], $info['sline_spacing'], $stext); } // the Text function will break it into lines $title_text = $svg_text->text($info['title'], $info['line_spacing'], $text); return $title_text . $subtitle_text; } /** * This should be overridden by subclass! */ abstract protected function draw(); /** * Displays the background image */ protected function backgroundImage($shadow, $clip_path) { $image_src = $this->getOption('back_image'); if(!$image_src) return ''; $width = $this->getOption('back_image_width'); $height = $this->getOption('back_image_height'); $left = $this->getOption('back_image_left'); $top = $this->getOption('back_image_top'); $mode = $this->getOption('back_image_mode'); if($shadow) { if($width === '100%') $width = new Number($this->width - $shadow); if($height === '100%') $height = new Number($this->height - $shadow); } $image = [ 'width' => $width, 'height' => $height, 'x' => $left, 'y' => $top, 'xlink:href' => $image_src, 'preserveAspectRatio' => ($mode == 'stretch' ? 'none' : 'xMinYMin') ]; if($clip_path !== null) $image['clip-path'] = 'url(#' . $clip_path . ')'; $style = []; if($this->getOption('back_image_opacity')) $style['opacity'] = $this->getOption('back_image_opacity'); $contents = ''; if($mode == 'tile') { $image['x'] = 0; $image['y'] = 0; $im = $this->element('image', $image, $style); $pattern = [ 'id' => $this->newID(), 'width' => $width, 'height' => $height, 'x' => $left, 'y' => $top, 'patternUnits' => 'userSpaceOnUse' ]; // tiled image becomes a pattern to replace background colour $this->defs->add($this->element('pattern', $pattern, null, $im)); $this->setOption('back_colour', 'url(#' . $pattern['id'] . ')'); } else { $im = $this->element('image', $image, $style); $contents .= $im; } return $contents; } /** * Displays the background */ protected function canvas($id) { $round = $this->getOption('back_round'); $stroke_width = $this->getOption('back_stroke_width'); $stroke_colour = new Colour($this, $this->getOption('back_stroke_colour')); $shadow_opacity = $this->getOption('back_shadow_opacity'); $shadow_blur = $this->getOption('back_shadow_blur'); $shadow = $this->getOption('back_shadow'); if($shadow === true) $shadow = 2; // background image can replace the back_colour option $bg = $this->backgroundImage($shadow, $round ? $id : null); $colour = new Colour($this, $this->getOption('back_colour')); $canvas = [ 'width' => '100%', 'height' => '100%', 'fill' => $colour, 'stroke-width' => 0, ]; $c_el = ''; if($shadow) { // shadow means canvas cannot be 100% of document $canvas['x'] = $canvas['y'] = new Number($stroke_width / 2); // blurring means using a filter and a larger offset if($shadow_blur) { $filter_opts = [ 'offset_x' => $shadow, 'offset_y' => $shadow, 'blur' => $shadow_blur, 'opacity' => $shadow_opacity, 'shadow_only' => true, ]; $shadow += $shadow_blur; $filter = $this->defs->addFilter('shadow', $filter_opts); } $canvas['width'] = new Number($this->width - $shadow - $stroke_width); $canvas['height'] = new Number($this->height - $shadow - $stroke_width); $filled = [ //'x' => $shadow, 'y' => $shadow, 'width' => new Number($this->width - $shadow), 'height' => new Number($this->height - $shadow), 'fill' => '#000', 'stroke-width' => 0, //'opacity' => $shadow_opacity, ]; if($shadow_blur) { $filled['filter'] = 'url(#' . $filter . ')'; } else { $filled['x'] = $shadow; $filled['y'] = $shadow; $filled['opacity'] = $shadow_opacity; } if($round) $filled['rx'] = $filled['ry'] = new Number($round); $c_el .= $this->element('rect', $filled); // increase padding to clear shadow $this->pad_right += $shadow; $this->pad_bottom += $shadow; } if($colour->opacity() < 1) $canvas['opacity'] = $colour->opacity(true); if($round) $canvas['rx'] = $canvas['ry'] = new Number($round); if($bg == '' && $stroke_width) { $canvas['stroke-width'] = $stroke_width; $canvas['stroke'] = $stroke_colour; } $c_el .= $this->element('rect', $canvas); // create a clip path for rounded rectangle if($round) { $this->defs->add($this->element('clipPath', ['id' => $id], null, $this->element('rect', $canvas))); } // if the background image is an element, insert it between the background // colour and border rect if($bg != '') { $c_el .= $bg; if($stroke_width) { $canvas['stroke-width'] = $stroke_width; $canvas['stroke'] = $stroke_colour; $canvas['fill'] = 'none'; $c_el .= $this->element('rect', $canvas); } } return $c_el; } /** * Displays readable (hopefully) error message */ protected function errorText($error) { if(!is_numeric($this->height)) $this->height = 100; $text = ['x' => 3, 'y' => $this->height - 3]; $style = [ 'font-family' => 'Courier New', 'font-size' => '11px', 'font-weight' => 'bold', ]; $e = $this->contrastText($text['x'], $text['y'], $error, 'blue', 'white', $style); return $e; } /** * Displays high-contrast text */ protected function contrastText($x, $y, $text, $fcolour = 'black', $bcolour = 'white', $properties = null, $styles = null) { $xform = new Transform; $xform->translate($x, $y); $props = ['transform' => $xform, 'fill' => $fcolour]; if(is_array($properties)) $props = array_merge($properties, $props); $bg = $this->element('text', ['stroke-width' => '2px', 'stroke' => $bcolour], null, $text); $fg = $this->element('text', null, null, $text); return $this->element('g', $props, $styles, $bg . $fg); } /** * Builds an element */ public function element($name, $attribs = null, $styles = null, $content = null, $no_whitespace = false) { if($this->namespace && strpos($name, ':') === false) $name = 'svg:' . $name; $element = '<' . $name; if(is_array($attribs)) { foreach($attribs as $attr => $val) { $value = new Attribute($attr, $val, $this->encoding); $element .= ' ' . $attr . '="' . $value . '"'; } } if(is_array($styles)) { $element .= ' style="'; foreach($styles as $attr => $val) { $value = new Attribute($attr, $val, $this->encoding); $element .= $attr . ':' . $value . ';'; } $element .= '"'; } if($content === null) $element .= "/>"; else $element .= '>' . $content . '"; if(!$no_whitespace) $element .= "\n"; return $element; } /** * Returns a link URL or NULL if none */ public function getLinkURL($item, $key, $row = 0) { $link = ($item === null ? null : $item->link); if(is_numeric($key)) $key = (int)round($key); if($link === null && is_array($this->links[$row]) && isset($this->links[$row][$key])) { $link = $this->links[$row][$key]; } // check for absolute links if($link !== null && strpos($link,'//') === false) $link = $this->link_base . $link; return $link; } /** * Retrieves a link */ public function getLink($item, $key, $content, $row = 0) { $link = $this->getLinkURL($item, $key, $row); if($link === null) return $content; $link_attr = ['xlink:href' => $link]; if(!empty($this->link_target)) $link_attr['target'] = $this->link_target; return $this->element('a', $link_attr, null, $content); } /** * Returns TRUE if the item is visible on the graph */ public function isVisible($item, $dataset = 0) { // default implementation is for all non-zero values to be visible return ($item->value != 0); } /** * Sets up the colour class */ protected function colourSetup($count, $datasets = null, $reverse = false) { $this->colours->setup($count, $datasets, $reverse); } /** * Returns a Colour */ public function getColour($item, $key, $dataset, $allow_gradient = true, $allow_pattern = true) { if($item !== null && $item->colour !== null) return new Colour($this, $item->colour, $allow_gradient, $allow_pattern); $c = $this->colours->getColour($key, $dataset); if($c === null) return new Colour($this, null); $colour = new Colour($this, $c, $allow_gradient, $allow_pattern); // make key reflect dataset as well (for gradients) if($dataset !== null) $key = $dataset . ':' . $key; if($key !== null) $colour->setGradientKey($key); return $colour; } /** * Returns the first non-empty option in named argument list. * Arguments must be "opt_name" or array("opt_name", $index), optionally * ending with default value (non-string or array('@', $value)) */ public function getOption($opt, $opt2 = null) { // single option - checking for null second option is faster // than using func_num_args() if($opt2 === null && is_string($opt)) { if(isset($this->settings[$opt]) && $this->settings[$opt] !== '') return $this->settings[$opt]; return null; } $opts = func_get_args(); foreach($opts as $opt) { // not string or array, default value if(!is_array($opt) && !is_string($opt)) return $opt; if(is_array($opt)) { // validate if(!isset($opt[0]) || !isset($opt[1]) || !is_string($opt[0])) throw new \InvalidArgumentException('Malformed option array'); // default value if($opt[0] === '@') return $opt[1]; list($name, $index) = $opt; if(isset($this->settings[$name])) { $val = $this->settings[$name]; if(is_array($val)) { if(isset($val[$index])) { $val = $val[$index]; } else { if(!is_numeric($index)) $index = 0; $val = $val[$index % count($val)]; } } if($val !== null && $val !== '') return $val; } continue; } // not an array if(isset($this->settings[$opt])) { $val = $this->settings[$opt]; if($val !== null && $val !== '') return $val; } } return null; } /** * Returns option from data item if present, or from settings if not */ public function getItemOption($option, $dataset, &$item, $item_option = null) { if($item_option === null) $item_option = $option; $value = null; if($item !== null) $value = $item->data($item_option); if($value === null) $value = $this->getOption([$option, $dataset]); return $value; } /** * Option setter */ public function setOption($name, $value, $index = null) { // very simple for now, might have to revisit it later if($index === null) { $this->settings[$name] = $value; return; } $this->settings[$name][$index] = $value; } /** * Returns the graph size and padding */ public function getDimensions() { $dimensions = [ 'width' => $this->width, 'height' => $this->height, 'pad_left' => $this->pad_left, 'pad_top' => $this->pad_top, 'pad_right' => $this->pad_right, 'pad_bottom' => $this->pad_bottom, ]; return $dimensions; } /** * Checks that the data are valid */ protected function checkValues() { if($this->values->error) throw new \Exception($this->values->error); } /** * Sets the stroke options for an element */ protected function setStroke(&$attr, &$item, $key, $dataset, $line_join = null) { unset($attr['stroke'], $attr['stroke-width'], $attr['stroke-linejoin'], $attr['stroke-dasharray']); $stroke_width = $this->getItemOption('stroke_width', $dataset, $item); if($stroke_width > 0) { $cg = new ColourGroup($this, $item, $key, $dataset); $attr['stroke'] = $cg->stroke(); if($attr['stroke']->opacity() < 1) $attr['stroke-opacity'] = $attr['stroke']->opacity(true); $attr['stroke-width'] = $stroke_width; if($line_join !== null) $attr['stroke-linejoin'] = $line_join; $dash = $this->getItemOption('stroke_dash', $dataset, $item); if(!empty($dash)) $attr['stroke-dasharray'] = $dash; } } /** * Creates a new ID for an element */ public function newID() { $prefix = (string)$this->getOption('id_prefix'); $i = ++Graph::$last_id; // id is case sensitive, so use lower and upper case $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; $iid = []; while($i > 0) { $c = $i % 62; $i = floor($i / 62); $iid[] = $chars[$c]; } return $prefix . 'e' . implode(array_reverse($iid)); } /** * Adds markup to be inserted between graph and legend */ public function addBackMatter($fragment) { $this->back_matter .= $fragment; } /** * Adds context menu for item */ protected function setContextMenu(&$element, $dataset, &$item, $duplicate = false) { if($this->context_menu === null) { $js = $this->getJavascript(); $this->context_menu = new ContextMenu($this, $js); } $this->context_menu->setMenu($element, $dataset, $item, $duplicate); } /** * Default tooltip contents are key and value, or whatever * $key is if $value is not set */ protected function setTooltip(&$element, &$item, $dataset, $key, $value = null, $duplicate = false) { $callback = $this->getOption('tooltip_callback'); $structure = $this->getOption('structure'); if(is_callable($callback)) { if($value === null) $value = $key; $text = call_user_func_array($callback, [$dataset, $key, $value]); } elseif(is_array($structure) && isset($structure['tooltip'])) { // use structured data tooltips if specified $text = $item->tooltip; } else { $text = $this->formatTooltip($item, $dataset, $key, $value); } if($text === null) return; $text = addslashes(str_replace("\n", '\n', $text)); return $this->getJavascript()->setTooltip($element, $text, $duplicate); } /** * Default format is value only */ protected function formatTooltip(&$item, $dataset, $key, $value) { $n = new Number($value, $this->getOption('units_tooltip'), $this->getOption('units_before_tooltip')); return $n->format(); } /** * Adds a data label to the list */ protected function addDataLabel($dataset, $index, &$element, &$item, $x, $y, $w, $h, $content = null, $duplicate = true) { if(!$this->getOption(['show_data_labels', $dataset])) return false; // set up fading for this label? $id = null; $fade_in = $this->getOption(['data_label_fade_in_speed', $dataset]); $fade_out = $this->getOption(['data_label_fade_out_speed', $dataset]); $click = $this->getOption(['data_label_click', $dataset]); $popup = $this->getOption(['data_label_popfront', $dataset]); if($click == 'hide' || $click == 'show') { $id = $this->newID(); $this->getJavascript()->setClickShow($element, $id, $click == 'hide', $duplicate); } if($popup) { if(!$id) $id = $this->newID(); $this->getJavascript()->setPopFront($element, $id, $duplicate); } if($fade_in || $fade_out) { $speed_in = $fade_in ? $fade_in / 100 : 0; $speed_out = $fade_out ? $fade_out / 100 : 0; if(!$id) $id = $this->newID(); $this->getJavascript()->setFader($element, $speed_in, $speed_out, $id, $duplicate); } $this->getDataLabels()->addLabel($dataset, $index, $item, $x, $y, $w, $h, $id, $content, $fade_in, $click); return true; } /** * Adds an element as a client of existing label */ protected function addLabelClient($dataset, $index, &$element) { $label = $this->getDataLabels()->getLabel($dataset, $index); if($label === null) return false; $id = $label['id']; $fade_in = $this->getOption(['data_label_fade_in_speed', $dataset]); $fade_out = $this->getOption(['data_label_fade_out_speed', $dataset]); $click = $this->getOption(['data_label_click', $dataset]); $popup = $this->getOption(['data_label_popfront', $dataset]); if($click == 'hide' || $click == 'show') $this->getJavascript()->setClickShow($element, $id, $click == 'hide', true); if($popup) $this->getJavascript()->setPopFront($element, $id, true); if($fade_in || $fade_out) { $speed_in = $fade_in ? $fade_in / 100 : 0; $speed_out = $fade_out ? $fade_out / 100 : 0; $this->getJavascript()->setFader($element, $speed_in, $speed_out, $id, true); } } /** * Adds a label for non-data text */ protected function addContentLabel($dataset, $index, $x, $y, $w, $h, $content) { $this->getDataLabels()->addContentLabel($dataset, $index, $x, $y, $w, $h, $content); return true; } /** * Draws the data labels */ protected function drawDataLabels() { if(isset($this->settings['label'])) $this->getDataLabels()->load($this->settings); return $this->getDataLabels()->getLabels(); } /** * Returns the position for a data label */ public function dataLabelPosition($dataset, $index, &$item, $x, $y, $w, $h, $label_w, $label_h) { $pos = $this->getOption(['data_label_position', $dataset]); if(empty($pos)) $pos = 'above'; $end = [$x + $w * 0.5, $y + $h * 0.5]; return [$pos, $end]; } /** * Returns the ShapeList instance */ public function getShapeList() { if($this->shapes === null) { $this->shapes = new ShapeList($this); $this->shapes->load($this->settings); } return $this->shapes; } public function underShapes() { if(!isset($this->settings['shape'])) return ''; return $this->getShapeList()->draw(ShapeList::BELOW); } public function overShapes() { if(!isset($this->settings['shape'])) return ''; return $this->getShapeList()->draw(ShapeList::ABOVE); } /** * Returns TRUE if the position is inside the item */ public static function isPositionInside($pos) { list($hpos, $vpos) = Graph::translatePosition($pos); return strpos($hpos . $vpos, 'o') === false; } /** * Sets the styles for data labels */ public function dataLabelStyle($dataset, $index, &$item) { // this function gets called a lot, so cache the return values if(isset($this->data_label_style_cache[$dataset])) return $this->data_label_style_cache[$dataset]; $map = $this->getDataLabels()->getStyleMap(); $style = []; foreach($map as $key => $option) { $style[$key] = $this->getOption([$option, $dataset]); } // padding x/y options override single value $style['pad_x'] = $this->getOption(['data_label_padding_x', $dataset], ['data_label_padding', $dataset]); $style['pad_y'] = $this->getOption(['data_label_padding_y', $dataset], ['data_label_padding', $dataset]); $this->data_label_style_cache[$dataset] = $style; return $style; } /** * Tail direction is required for some types of label */ public function dataLabelTailDirection($dataset, $index, $hpos, $vpos) { // angle starts at right, goes clockwise $angle = 90; $pos = str_replace(['i', 'o', 'm'], '', $vpos) . str_replace(['i', 'o', 'c'], '', $hpos); switch($pos) { case 'l' : $angle = 0; break; case 'tl' : $angle = 45; break; case 't' : $angle = 90; break; case 'tr' : $angle = 135; break; case 'r' : $angle = 180; break; case 'br' : $angle = 225; break; case 'b' : $angle = 270; break; case 'bl' : $angle = 315; break; } return $angle; } /** * Builds and returns the body of the graph */ private function buildGraph() { $this->checkValues($this->values); // body content comes from the subclass return $this->drawGraph(); } /** * Returns the SVG document */ public function fetch($header = true, $defer_javascript = true) { $content = ''; if($header) { $content .= 'encoding) > 0) $content .= ' encoding="' . $this->encoding . '"'; $content .= ' standalone="no"?' . ">\n"; if($this->getOption('doctype')) $content .= '' . "\n"; } // set the precision - PHP default is 14 digits! $old_precision = ini_set('precision', $this->settings['precision']); Number::setup($this->settings['precision'], $this->settings['decimal'], $this->settings['thousands']); $heading = $foot = ''; // display title and description if available if($this->getOption('title')) $heading .= $this->element('title', null, null, $this->getOption('title')); if($this->getOption('description')) $heading .= $this->element('desc', null, null, $this->getOption('description')); try { $body = $this->buildGraph(); } catch(\Exception $e) { if($this->getOption('exception_throw')) throw $e; $err = $e->getMessage(); $details = $this->getOption('exception_details'); if($details) $err .= ' [' . basename($e->getFile()) . ' #' . $e->getLine() . ']'; $body = $this->errorText($err); if($details) { $body .= "\n"; } } $svg = [ 'version' => '1.1', 'width' => new Number($this->width), 'height' => new Number($this->height), ]; if($this->subgraph) { // subgraphs need x and y, and can overflow $x = $this->getOption('graph_x'); $y = $this->getOption('graph_y'); if($x) $svg['x'] = $x; if($y) $svg['y'] = $y; $svg['overflow'] = 'visible'; } else { if($this->namespace) $svg['xmlns:svg'] = 'http://www.w3.org/2000/svg'; else $svg['xmlns'] = 'http://www.w3.org/2000/svg'; $svg['xmlns:xlink'] = 'http://www.w3.org/1999/xlink'; // add any extra namespaces foreach($this->namespaces as $ns => $url) $svg['xmlns:' . $ns] = $url; if($this->getOption('auto_fit')) { // convert pixel size to viewbox size $svg['viewBox'] = '0 0 ' . $svg['width'] . ' ' . $svg['height']; $svg['width'] = $svg['height'] = '100%'; } } if($this->getOption('svg_class')) $svg['class'] = $this->getOption('svg_class'); if(!$defer_javascript) $foot .= $this->fetchJavascript(true, !$this->namespace); // add defs to heading $heading .= $this->defs->get(); // display version string if($this->getOption('show_version')) { $text = ['x' => $this->pad_left, 'y' => $this->height - 3]; $style = [ 'font-family' => 'Courier New', 'font-size' => '12px', 'font-weight' => 'bold', ]; $body .= $this->contrastText($text['x'], $text['y'], SVGGraph::VERSION, 'blue', 'white', $style); } $content .= $this->element('svg', $svg, null, $heading . $body . $foot); // replace PHP's precision ini_set('precision', $old_precision); if($this->getOption('minify')) $content = preg_replace('/\>\s+\<', $content); return $content; } /** * Renders the SVG document */ public function render($header = true, $content_type = true, $defer_javascript = false) { $mime_header = 'Content-type: image/svg+xml; charset=UTF-8'; if($content_type) header($mime_header); try { echo $this->fetch($header, $defer_javascript); } catch(\Exception $e) { if($this->getOption('exception_throw')) throw $e; $this->errorText($e); } } /** * When using the defer_javascript option, this returns the * Javascript block */ public function fetchJavascript($cdata = true, $no_namespace = true) { if(!isset(Graph::$javascript)) return ''; $script = Graph::$javascript->getCode($cdata, $this->getOption('minify_js')); if($script == '') return ''; $script_attr = ['type' => 'application/ecmascript']; $namespace = $this->namespace; if($no_namespace) $this->namespace = false; $js = $this->element('script', $script_attr, null, $script); if($no_namespace) $this->namespace = $namespace; return $js; } /** * Returns the minimum value in the array, ignoring NULLs */ public static function min(&$a) { $min = null; foreach($a as $v) { if($v !== null && ($min === null || $v < $min)) $min = $v; } return $min; } /** * Converts a string key to a unix timestamp, or NULL if invalid */ public static function dateConvert($k) { // date_create functions return false if the conversion fails $dt = false; // if the format is set, try it if(Graph::$key_format !== null) $dt = date_create_from_format(Graph::$key_format, $k); // try default conversion if($dt === false) $dt = date_create($k); // give up if($dt === false) return null; // this works in 64-bit on 32-bit systems, getTimestamp() doesn't return $dt->format('U'); } }