From 4b55bcf8b6e86d56aa0ba50faf6166ca0a00e4d6 Mon Sep 17 00:00:00 2001
From: Matthias Jentsch <matthias.jentsch@netways.de>
Date: Tue, 2 Sep 2014 12:24:29 +0200
Subject: [PATCH] Add tooltips to bar charts

Add a class to format and populate tooltips from graph data sets and implement
those tooltips in the ChartController.
---
 library/Icinga/Chart/Graph/BarGraph.php       | 104 ++++++++++---
 library/Icinga/Chart/Graph/StackedGraph.php   |   4 +
 library/Icinga/Chart/Graph/Tooltip.php        | 144 ++++++++++++++++++
 library/Icinga/Chart/GridChart.php            |  40 ++++-
 .../controllers/ChartController.php           |  23 ++-
 5 files changed, 286 insertions(+), 29 deletions(-)
 create mode 100644 library/Icinga/Chart/Graph/Tooltip.php

diff --git a/library/Icinga/Chart/Graph/BarGraph.php b/library/Icinga/Chart/Graph/BarGraph.php
index 968e4c0f7..3d6a5c234 100644
--- a/library/Icinga/Chart/Graph/BarGraph.php
+++ b/library/Icinga/Chart/Graph/BarGraph.php
@@ -16,6 +16,13 @@ use Icinga\Chart\Render\RenderContext;
  */
 class BarGraph extends Styleable implements Drawable
 {
+    /**
+     * The dataset order
+     *
+     * @var int
+     */
+    private $order = 0;
+
     /**
      * The width of the bars.
      *
@@ -30,14 +37,37 @@ class BarGraph extends Styleable implements Drawable
      */
     private $dataSet;
 
+    /**
+     * The tooltips
+     *
+     * @var
+     */
+    private $tooltips;
+
+    /**
+     * All graphs
+     *
+     * @var
+     */
+    private $graphs;
+
     /**
      * Create a new BarGraph with the given dataset
      *
-     * @param array $dataSet    An array of datapoints
+     * @param array $dataSet    An array of data points
+     * @param int   $order      The graph number displayed by this BarGraph
+     * @param array $tooltips   The tooltips to display for each value
      */
-    public function __construct(array $dataSet)
-    {
+    public function __construct(
+        array $dataSet,
+        array &$graphs,
+        $order,
+        array $tooltips = null
+    ) {
+        $this->order = $order;
         $this->dataSet = $dataSet;
+        $this->tooltips = $tooltips;
+        $this->graphs = $graphs;
     }
 
     /**
@@ -56,6 +86,30 @@ class BarGraph extends Styleable implements Drawable
         }
     }
 
+    /**
+     * Draw a single rectangle
+     *
+     * @param array     $point          The
+     * @param null      $index
+     * @param string    $fill           The fill color to use
+     * @param           $strokeWidth
+     *
+     * @return Rect
+     */
+    private function drawSingleBar($point, $index = null, $fill, $strokeWidth)
+    {
+        $rect = new Rect($point[0] - ($this->barWidth / 2), $point[1], $this->barWidth, 100 - $point[1]);
+        $rect->setFill($fill);
+        $rect->setStrokeWidth($strokeWidth);
+        $rect->setStrokeColor('black');
+        if (isset($index)) {
+            $rect->setAttribute('data-icinga-graph-index', $index);
+        }
+        $rect->setAttribute('data-icinga-graph-type', 'bar');
+        $rect->setAdditionalStyle('clip-path: url(#clip);');
+        return $rect;
+    }
+
     /**
      * Render this BarChart
      *
@@ -68,23 +122,33 @@ class BarGraph extends Styleable implements Drawable
         $doc = $ctx->getDocument();
         $group = $doc->createElement('g');
         $idx = 0;
-        foreach ($this->dataSet as $point) {
-            $rect = new Rect($point[0] - 2, $point[1], 4, 100 - $point[1]);
-            $rect->setFill($this->fill);
-            $rect->setStrokeWidth($this->strokeWidth);
-            $rect->setStrokeColor('black');
-            $rect->setAttribute('data-icinga-graph-index', $idx++);
-            $rect->setAttribute('data-icinga-graph-type', 'bar');
-            $rect->setAdditionalStyle('clip-path: url(#clip);');
-            /*$rect->setAnimation(
-                new Animation(
-                    'y',
-                    $ctx->yToAbsolute(100),
-                    $ctx->yToAbsolute($point[1]),
-                    rand(1, 1.5)/2
-                )
-            );*/
-            $group->appendChild($rect->toSvg($ctx));
+        foreach ($this->dataSet as $x => $point) {
+            // add white background bar, to prevent other bars from altering transparency effects
+            $bar = $this->drawSingleBar($point, $idx++, 'white', $this->strokeWidth, $idx)->toSvg($ctx);
+            $group->appendChild($bar);
+
+            // draw actual bar
+            $bar = $this->drawSingleBar($point, null, $this->fill, $this->strokeWidth, $idx)->toSvg($ctx);
+            $bar->setAttribute('class', 'chart-data');
+            if (isset($this->tooltips[$x])) {
+                $data = array(
+                    'label' => isset($this->graphs[$this->order]['label']) ?
+                            strtolower($this->graphs[$this->order]['label']) : '',
+                    'color' => isset($this->graphs[$this->order]['color']) ?
+                            strtolower($this->graphs[$this->order]['color']) : '#fff'
+                );
+                $format = isset($this->graphs[$this->order]['tooltip'])
+                    ? $this->graphs[$this->order]['tooltip'] : null;
+                $bar->setAttribute(
+                    'title',
+                    $this->tooltips[$x]->renderNoHtml($this->order, $data, $format)
+                );
+                $bar->setAttribute(
+                    'title-rich',
+                    $this->tooltips[$x]->render($this->order, $data, $format)
+                );
+            }
+            $group->appendChild($bar);
         }
         return $group;
     }
diff --git a/library/Icinga/Chart/Graph/StackedGraph.php b/library/Icinga/Chart/Graph/StackedGraph.php
index ae4f593e2..4339b8c8d 100644
--- a/library/Icinga/Chart/Graph/StackedGraph.php
+++ b/library/Icinga/Chart/Graph/StackedGraph.php
@@ -41,6 +41,10 @@ class StackedGraph implements Drawable
             if (!isset($this->points[$x])) {
                 $this->points[$x] = 0;
             }
+            // store old y-value for displaying the actual (non-aggregated)
+            // value in the tooltip
+            $point[2] = $point[1];
+
             $this->points[$x] += $point[1];
             $point[1] = $this->points[$x];
         }
diff --git a/library/Icinga/Chart/Graph/Tooltip.php b/library/Icinga/Chart/Graph/Tooltip.php
new file mode 100644
index 000000000..b630c7c61
--- /dev/null
+++ b/library/Icinga/Chart/Graph/Tooltip.php
@@ -0,0 +1,144 @@
+<?php
+// {{{ICINGA_LICENSE_HEADER}}}
+// {{{ICINGA_LICENSE_HEADER}}}
+
+namespace Icinga\Chart\Graph;
+
+/**
+ * A tooltip that stores and aggregates information about displayed data
+ * points of a graph and replaces them in a format string to render the description
+ * for specific data points of the graph.
+ *
+ * When render() is called, placeholders for the keys for each data entry will be replaced by
+ * the current value of this data set and the formatted string will be returned.
+ * The content of the replaced keys can change for each data set and depends on how the data
+ * is passed to this class. There are several types of properties:
+ *
+ * <ul>
+ *  <li>Global properties</li>:     Key-value pairs that stay the same every time render is called, and are
+ *                                  passed to an instance in the constructor.
+ *  <li>Aggregated properties</li>: Global properties that are created automatically from
+ *                                  all attached data points.
+ *  <li>Local properties</li>:      Key-value pairs that only apply to a single data point and
+ *                                  are passed to the render-function.
+ * </ul>
+ */
+class Tooltip
+{
+    /**
+     * The default format string used
+     * when no other format is specified
+     *
+     * @var string
+     */
+    private $defaultFormat;
+
+    /**
+     * All aggregated points
+     *
+     * @var array
+     */
+    private $points = array();
+
+    /**
+     * Contains all static replacements
+     *
+     * @var array
+     */
+    private $data = array(
+        'sum' => 0
+    );
+
+    /**
+     * Used to format the displayed tooltip.
+     *
+     * @var string
+     */
+    protected $tooltipFormat;
+
+    /**
+     * Create a new tooltip with the specified default format string
+     *
+     * Allows you to set the global data for this tooltip, that is displayed every
+     * time render is called.
+     *
+     * @param array     $data   Map of global properties
+     * @param string    $format The default format string
+     */
+    public function __construct (
+        $data = array(),
+        $format = '<b>{title}</b></b><br> {value} of {sum} {label}'
+    ) {
+        $this->data = array_merge($this->data, $data);
+        $this->defaultFormat = $format;
+    }
+
+    /**
+     * Add a single data point to update the aggregated properties for this tooltip
+     *
+     * @param $point  array   Contains the (x,y) values of the data set
+     */
+    public function addDataPoint($point)
+    {
+        // set x-value
+        if (!isset($this->data['title'])) {
+            $this->data['title'] = $point[0];
+        }
+
+        // aggregate y-values
+        $y = (int)$point[1];
+        if (isset($point[2])) {
+            // load original value in case value already aggregated
+            $y = (int)$point[2];
+        }
+
+        if (!isset($this->data['min']) || $this->data['min'] > $y) {
+            $this->data['min'] = $y;
+        }
+        if (!isset($this->data['max']) || $this->data['max'] < $y) {
+            $this->data['max'] = $y;
+        }
+        $this->data['sum'] += $y;
+        $this->points[] = $y;
+    }
+
+    /**
+     * Format the tooltip for a certain data point
+     *
+     * @param array     $order      Which data set to render
+     * @param array     $data       The local data for this tooltip
+     * @param string    $format     Use a custom format string for this data set
+     *
+     * @return mixed|string The tooltip value
+     */
+    public function render($order, $data = array(), $format = null)
+    {
+        if (isset($format)) {
+            $str = $format;
+        } else {
+            $str = $this->defaultFormat;
+        }
+        $data['value'] = $this->points[$order];
+        foreach (array_merge($this->data, $data) as $key => $value) {
+            $str = str_replace('{' . $key . '}', $value, $str);
+        }
+        return $str;
+    }
+
+    /**
+     * Format the tooltip for a certain data point but remove all
+     * occurring html tags
+     *
+     * This is useful for rendering clean tooltips on client without JavaScript
+     *
+     * @param array     $order      Which data set to render
+     * @param array     $data       The local data for this tooltip
+     * @param string    $format     Use a custom format string for this data set
+     *
+     * @return mixed|string The tooltip value, without any HTML tags
+     */
+    public function renderNoHtml($order, $data, $format)
+    {
+        return strip_tags($this->render($order, $data, $format));
+    }
+}
\ No newline at end of file
diff --git a/library/Icinga/Chart/GridChart.php b/library/Icinga/Chart/GridChart.php
index ef1682921..190e09bf9 100644
--- a/library/Icinga/Chart/GridChart.php
+++ b/library/Icinga/Chart/GridChart.php
@@ -10,6 +10,7 @@ use Icinga\Chart\Axis;
 use Icinga\Chart\Graph\BarGraph;
 use Icinga\Chart\Graph\LineGraph;
 use Icinga\Chart\Graph\StackedGraph;
+use Icinga\Chart\Graph\Tooltip;
 use Icinga\Chart\Primitive\Canvas;
 use Icinga\Chart\Primitive\Rect;
 use Icinga\Chart\Primitive\Path;
@@ -74,6 +75,16 @@ class GridChart extends Chart
      */
     private $stacks = array();
 
+    /**
+     * An associative array containing all Tooltips used to render the titles
+     *
+     * Each tooltip represents the summary for all y-values of a certain x-value
+     * in the grid chart
+     *
+     * @var Tooltip
+     */
+    private $tooltips = array();
+
     /**
      * Check if the current dataset has the proper structure for this chart.
      *
@@ -169,6 +180,26 @@ class GridChart extends Chart
                 $this->legend->addDataset($graph);
             }
         }
+        $this->initTooltips($data);
+    }
+
+
+    private function initTooltips($data)
+    {
+        foreach ($data as &$graph) {
+            foreach  ($graph['data'] as $x => $point) {
+                if (!array_key_exists($x, $this->tooltips)) {
+                    $this->tooltips[$x] = new Tooltip(
+                        array(
+                            'color' => $graph['color'],
+
+                        )
+
+                    );
+                }
+                $this->tooltips[$x]->addDataPoint($point);
+            }
+        }
     }
 
     /**
@@ -353,11 +384,16 @@ class GridChart extends Chart
         foreach ($this->graphs as $axisName => $graphs) {
             $axis = $this->axis[$axisName];
             $graphObj = null;
-            foreach ($graphs as $graph) {
+            foreach ($graphs as $dataset => $graph) {
                 // determine the type and create a graph object for it
                 switch ($graph['graphType']) {
                     case self::TYPE_BAR:
-                        $graphObj = new BarGraph($axis->transform($graph['data']));
+                        $graphObj = new BarGraph(
+                            $axis->transform($graph['data']),
+                            $graphs,
+                            $dataset,
+                            $this->tooltips
+                        );
                         break;
                     case self::TYPE_LINE:
                         $graphObj = new LineGraph($axis->transform($graph['data']));
diff --git a/modules/monitoring/application/controllers/ChartController.php b/modules/monitoring/application/controllers/ChartController.php
index 305ff92bc..af6240259 100644
--- a/modules/monitoring/application/controllers/ChartController.php
+++ b/modules/monitoring/application/controllers/ChartController.php
@@ -154,30 +154,35 @@ class Monitoring_ChartController extends Controller
             ->setXAxis(new \Icinga\Chart\Unit\StaticAxis())
             ->setAxisMin(null, 0);
 
+        $tooltip = t('<b>{title}:</b><br />{value} of {sum} services are {label}');
         $this->view->chart->drawBars(
             array(
                 'label' => t('Ok'),
                 'color' => '#44bb77',
                 'stack' => 'stack1',
-                'data'  => $okBars
+                'data'  => $okBars,
+                'tooltip' => $tooltip
             ),
             array(
                 'label' => t('Warning'),
                 'color' => '#ffaa44',
                 'stack' => 'stack1',
-                'data'  => $warningBars
+                'data'  => $warningBars,
+                'tooltip' => $tooltip
             ),
             array(
                 'label' => t('Critical'),
                 'color' => '#ff5566',
                 'stack' => 'stack1',
-                'data'  => $critBars
+                'data'  => $critBars,
+                'tooltip' => $tooltip
             ),
             array(
                 'label' => t('Unknown'),
                 'color' => '#dd66ff',
                 'stack' => 'stack1',
-                'data'  => $unknownBars
+                'data'  => $unknownBars,
+                'tooltip' => $tooltip
             )
         );
     }
@@ -201,6 +206,7 @@ class Monitoring_ChartController extends Controller
                 $hostgroup->hosts_unreachable_unhandled
             );
         }
+        $tooltip = t('<b>{title}:</b><br /> {value} of {sum} hosts are {label}');
         $this->view->chart = new GridChart();
         $this->view->chart->alignTopLeft();
         $this->view->chart->setAxisLabel('', t('Hosts'))
@@ -211,19 +217,22 @@ class Monitoring_ChartController extends Controller
                 'label' => t('Up'),
                 'color' => '#44bb77',
                 'stack' => 'stack1',
-                'data'  => $upBars
+                'data'  => $upBars,
+                'tooltip' => $tooltip
             ),
             array(
                 'label' => t('Down'),
                 'color' => '#ff5566',
                 'stack' => 'stack1',
-                'data'  => $downBars
+                'data'  => $downBars,
+                'tooltip' => $tooltip
             ),
             array(
                 'label' => t('Unreachable'),
                 'color' => '#dd66ff',
                 'stack' => 'stack1',
-                'data'  => $unreachableBars
+                'data'  => $unreachableBars,
+                'tooltip' => $tooltip
             )
         );
     }