summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Chart/Donut.php
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icinga/Chart/Donut.php')
-rw-r--r--library/Icinga/Chart/Donut.php465
1 files changed, 465 insertions, 0 deletions
diff --git a/library/Icinga/Chart/Donut.php b/library/Icinga/Chart/Donut.php
new file mode 100644
index 0000000..9d2a2a8
--- /dev/null
+++ b/library/Icinga/Chart/Donut.php
@@ -0,0 +1,465 @@
+<?php
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Chart;
+
+use Icinga\Web\Url;
+
+/** Donut chart implementation */
+class Donut
+{
+ /**
+ * Big label in the middle of the donut, color is critical (red)
+ *
+ * @var string
+ */
+ protected $labelBig;
+
+ /**
+ * Url behind the big label
+ *
+ * @var Url
+ */
+ protected $labelBigUrl;
+
+ /**
+ * The state the big label shall indicate
+ *
+ * @var string|null
+ */
+ protected $labelBigState = 'critical';
+
+ /**
+ * Small label in the lower part of the donuts hole
+ *
+ * @var string
+ */
+ protected $labelSmall;
+
+ /**
+ * Thickness of the donut ring
+ *
+ * @var int
+ */
+ protected $thickness = 6;
+
+ /**
+ * Radius based of 100 to simplify the calculations
+ *
+ * 100 / (2 * M_PI)
+ *
+ * @var float
+ */
+ protected $radius = 15.9154943092;
+
+ /**
+ * Color of the hole in the donut
+ *
+ * Transparent by default so it can be placed anywhere with ease
+ *
+ * @var string
+ */
+ protected $centerColor = 'transparent';
+
+ /**
+ * The different colored parts that represent the data
+ *
+ * @var array
+ */
+ protected $slices = array();
+
+ /**
+ * The total amount of data units
+ *
+ * @var int
+ */
+ protected $count = 0;
+
+ /**
+ * Adds a colored part that represent the data
+ *
+ * @param integer $data Units of data
+ * @param array $attributes HTML attributes for this slice. (For example ['class' => 'slice-state-ok'])
+ *
+ * @return $this
+ */
+ public function addSlice($data, $attributes = array())
+ {
+ $this->slices[] = array($data, $attributes);
+
+ $this->count += $data;
+
+ return $this;
+ }
+
+ /**
+ * Set the thickness for this Donut
+ *
+ * @param integer $thickness
+ *
+ * @return $this
+ */
+ public function setThickness($thickness)
+ {
+ $this->thickness = $thickness;
+
+ return $this;
+ }
+ /**
+ * Get the thickness for this Donut
+ *
+ * @return integer
+ */
+ public function getThickness()
+ {
+ return $this->thickness;
+ }
+
+ /**
+ * Set the center color for this Donut
+ *
+ * @param string $centerColor
+ *
+ * @return $this
+ */
+ public function setCenterColor($centerColor)
+ {
+ $this->centerColor = $centerColor;
+
+ return $this;
+ }
+
+ /**
+ * Get the center color for this Donut
+ *
+ * @return string
+ */
+ public function getCenterColor()
+ {
+ return $this->centerColor;
+ }
+
+ /**
+ * Set the text of the big label
+ *
+ * @param string $labelBig
+ *
+ * @return $this
+ */
+ public function setLabelBig($labelBig)
+ {
+ $this->labelBig = $labelBig;
+
+ return $this;
+ }
+
+ /**
+ * Get the text of the big label
+ *
+ * @return string
+ */
+ public function getLabelBig()
+ {
+ return $this->labelBig;
+ }
+
+ /**
+ * Set the url behind the big label
+ *
+ * @param Url $labelBigUrl
+ *
+ * @return $this
+ */
+ public function setLabelBigUrl($labelBigUrl)
+ {
+ $this->labelBigUrl = $labelBigUrl;
+
+ return $this;
+ }
+
+ /**
+ * Get the url behind the big label
+ *
+ * @return Url
+ */
+ public function getLabelBigUrl()
+ {
+ return $this->labelBigUrl;
+ }
+
+ /**
+ * Get whether the big label shall be eye-catching
+ *
+ * @return bool
+ */
+ public function getLabelBigEyeCatching()
+ {
+ return $this->labelBigState !== null;
+ }
+
+ /**
+ * Set whether the big label shall be eye-catching
+ *
+ * @param bool $labelBigEyeCatching
+ *
+ * @return $this
+ */
+ public function setLabelBigEyeCatching($labelBigEyeCatching = true)
+ {
+ $this->labelBigState = $labelBigEyeCatching ? 'critical' : null;
+
+ return $this;
+ }
+
+ /**
+ * Get the state the big label shall indicate
+ *
+ * @return string|null
+ */
+ public function getLabelBigState()
+ {
+ return $this->labelBigState;
+ }
+
+ /**
+ * Set the state the big label shall indicate
+ *
+ * @param string|null $labelBigState
+ *
+ * @return $this
+ */
+ public function setLabelBigState($labelBigState)
+ {
+ $this->labelBigState = $labelBigState;
+
+ return $this;
+ }
+
+ /**
+ * Set the text of the small label
+ *
+ * @param string $labelSmall
+ *
+ * @return $this
+ */
+ public function setLabelSmall($labelSmall)
+ {
+ $this->labelSmall = $labelSmall;
+
+ return $this;
+ }
+
+ /**
+ * Get the text of the small label
+ *
+ * @return string
+ */
+ public function getLabelSmall()
+ {
+ return $this->labelSmall;
+ }
+
+ /**
+ * Put together all slices of this Donut
+ *
+ * @return array $svg
+ */
+ protected function assemble()
+ {
+ // svg tag containing the ring
+ $svg = array(
+ 'tag' => 'svg',
+ 'attributes' => array(
+ 'xmlns' => 'http://www.w3.org/2000/svg',
+ 'viewbox' => '0 0 40 40',
+ 'class' => 'donut-graph'
+ ),
+ 'content' => array()
+ );
+
+ // Donut hole
+ $svg['content'][] = array(
+ 'tag' => 'circle',
+ 'attributes' => array(
+ 'cx' => 20,
+ 'cy' => 20,
+ 'r' => sprintf('%F', $this->radius),
+ 'fill' => $this->getCenterColor()
+ )
+ );
+
+ // When there is no data show gray circle
+ $svg['content'][] = array(
+ 'tag' => 'circle',
+ 'attributes' => array(
+ 'aria-hidden' => true,
+ 'cx' => 20,
+ 'cy' => 20,
+ 'r' => sprintf('%F', $this->radius),
+ 'fill' => $this->getCenterColor(),
+ 'stroke-width' => $this->getThickness(),
+ 'class' => 'slice-state-not-checked'
+ )
+ );
+
+ $slices = $this->slices;
+
+ if ($this->count !== 0) {
+ array_walk($slices, function (&$slice) {
+ $slice[0] = round(100 / $this->count * $slice[0], 2);
+ });
+ }
+
+ // on 0 the donut would start at "3 o'clock" and the offset shifts counterclockwise
+ $offset = 25;
+
+ foreach ($slices as $slice) {
+ $svg['content'][] = array(
+ 'tag' => 'circle',
+ 'attributes' => $slice[1] + array(
+ 'cx' => 20,
+ 'cy' => 20,
+ 'r' => sprintf('%F', $this->radius),
+ 'fill' => 'transparent',
+ 'stroke-width' => $this->getThickness(),
+ 'stroke-dasharray' => sprintf('%F', $slice[0])
+ . ' '
+ . sprintf('%F', (99.9 - $slice[0])), // 99.9 prevents gaps (slight overlap)
+ 'stroke-dashoffset' => sprintf('%F', $offset)
+ )
+ );
+ // negative values shift in the clockwise direction
+ $offset -= $slice[0];
+ }
+
+ $result = array(
+ 'tag' => 'div',
+ 'content' => array($svg)
+ );
+
+ $labelBig = (string) $this->getLabelBig();
+ $labelSmall = (string) $this->getLabelSmall();
+
+ if ($labelBig !== '' || $labelSmall !== '') {
+ $labels = array(
+ 'tag' => 'div',
+ 'attributes' => array(
+ 'class' => 'donut-label'
+ ),
+ 'content' => array()
+ );
+
+ if ($labelBig !== '') {
+ $labels['content'][] =
+ array(
+ 'tag' => 'a',
+ 'attributes' => array(
+ 'aria-label' => $labelBig . ' ' . $labelSmall,
+ 'href' => $this->getLabelBigUrl() ? $this->getLabelBigUrl()->getAbsoluteUrl() : null,
+ 'class' => $this->labelBigState === null
+ ? 'donut-label-big'
+ : 'donut-label-big state-' . $this->labelBigState
+ ),
+ 'content' => $this->shortenLabel($labelBig)
+ );
+ }
+
+ if ($labelSmall !== '') {
+ $labels['content'][] = array(
+ 'tag' => 'p',
+ 'attributes' => array(
+ 'class' => 'donut-label-small',
+ 'x' => '50%',
+ 'y' => '50%'
+ ),
+ 'content' => $labelSmall
+ );
+ }
+
+ $result['content'][] = $labels;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Shorten the label to 3 digits if it is numeric
+ *
+ * 10 => 10 ... 1111 => ~1k ... 1888 => ~2k
+ *
+ * @param int|string $label
+ *
+ * @return string
+ */
+ protected function shortenLabel($label)
+ {
+ if (is_numeric($label) && strlen($label) > 3) {
+ return round($label, -3)/1000 . 'k';
+ }
+
+ return $label;
+ }
+
+ protected function encode($content)
+ {
+ return htmlspecialchars($content, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', true);
+ }
+
+ protected function renderAttributes(array $attributes)
+ {
+ $html = array();
+
+ foreach ($attributes as $name => $value) {
+ if ($value === null) {
+ continue;
+ }
+
+ if (is_bool($value) && $value) {
+ $html[] = $name;
+ continue;
+ }
+
+ if (is_array($value)) {
+ $value = implode(' ', $value);
+ }
+
+ $html[] = "$name=\"" . $this->encode($value) . '"';
+ }
+
+ return implode(' ', $html);
+ }
+
+ protected function renderContent(array $element)
+ {
+ $tag = $element['tag'];
+ $attributes = isset($element['attributes']) ? $element['attributes'] : array();
+ $content = isset($element['content']) ? $element['content'] : null;
+
+ $html = array(
+ // rtrim because attributes may be empty
+ rtrim("<$tag " . $this->renderAttributes($attributes))
+ . ">"
+ );
+
+ if ($content !== null) {
+ if (is_array($content)) {
+ foreach ($content as $child) {
+ $html[] = is_array($child) ? $this->renderContent($child) : $this->encode($child);
+ }
+ } else {
+ $html[] = $this->encode($content);
+ }
+ }
+
+ $html[] = "</$tag>";
+
+ return implode("\n", $html);
+ }
+
+ public function render()
+ {
+ $svg = $this->assemble();
+
+ return $this->renderContent($svg);
+ }
+}