diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:39:39 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:39:39 +0000 |
commit | 8ca6cc32b2c789a3149861159ad258f2cb9491e3 (patch) | |
tree | 2492de6f1528dd44eaa169a5c1555026d9cb75ec /library/Icinga/Chart | |
parent | Initial commit. (diff) | |
download | icingaweb2-8ca6cc32b2c789a3149861159ad258f2cb9491e3.tar.xz icingaweb2-8ca6cc32b2c789a3149861159ad258f2cb9491e3.zip |
Adding upstream version 2.11.4.upstream/2.11.4upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
35 files changed, 5839 insertions, 0 deletions
diff --git a/library/Icinga/Chart/Axis.php b/library/Icinga/Chart/Axis.php new file mode 100644 index 0000000..1639939 --- /dev/null +++ b/library/Icinga/Chart/Axis.php @@ -0,0 +1,485 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMElement; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Primitive\Line; +use Icinga\Chart\Primitive\Text; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Render\Rotator; +use Icinga\Chart\Unit\AxisUnit; +use Icinga\Chart\Unit\CalendarUnit; +use Icinga\Chart\Unit\LinearUnit; + +/** + * Axis class for the GridChart class. + * + * Implements drawing functions for the axis and its labels but delegates tick and label calculations + * to the AxisUnit implementations + * + * @see GridChart + * @see AxisUnit + */ +class Axis implements Drawable +{ + /** + * Draw the label text horizontally + */ + const LABEL_ROTATE_HORIZONTAL = 'normal'; + + /** + * Draw the label text diagonally + */ + const LABEL_ROTATE_DIAGONAL = 'diagonal'; + + /** + * Whether to draw the horizontal lines for the background grid + * + * @var bool + */ + private $drawXGrid = true; + + /** + * Whether to draw the vertical lines for the background grid + * + * @var bool + */ + private $drawYGrid = true; + + /** + * The label for the x axis + * + * @var string + */ + private $xLabel = ""; + + /** + * The label for the y axis + * + * @var string + */ + private $yLabel = ""; + + /** + * The AxisUnit implementation to use for calculating the ticks for the x axis + * + * @var AxisUnit + */ + private $xUnit = null; + + /** + * The AxisUnit implementation to use for calculating the ticks for the y axis + * + * @var AxisUnit + */ + private $yUnit = null; + + /** + * The minimum amount of units each step must take up + * + * @var int + */ + public $minUnitsPerStep = 80; + + /** + * The minimum amount of units each tick must take up + * + * @var int + */ + public $minUnitsPerTick = 15; + + /** + * If the displayed labels should be aligned horizontally or diagonally + */ + protected $labelRotationStyle = self::LABEL_ROTATE_HORIZONTAL; + + /** + * Inform the axis about an added dataset + * + * This is especially needed when one or more AxisUnit implementations dynamically define + * their min or max values, as this is the point where they detect the min and max value + * from the datasets + * + * @param array $dataset An dataset to respect on axis generation + */ + public function addDataset(array $dataset) + { + $this->xUnit->addValues($dataset, 0); + $this->yUnit->addValues($dataset, 1); + } + + /** + * Set the AxisUnit implementation to use for generating the x axis + * + * @param AxisUnit $unit The AxisUnit implementation to use for the x axis + * + * @return $this This Axis Object + * @see Axis::CalendarUnit + * @see Axis::LinearUnit + */ + public function setUnitForXAxis(AxisUnit $unit) + { + $this->xUnit = $unit; + return $this; + } + + /** + * Set the AxisUnit implementation to use for generating the y axis + * + * @param AxisUnit $unit The AxisUnit implementation to use for the y axis + * + * @return $this This Axis Object + * @see Axis::CalendarUnit + * @see Axis::LinearUnit + */ + public function setUnitForYAxis(AxisUnit $unit) + { + $this->yUnit = $unit; + return $this; + } + + /** + * Return the padding this axis requires + * + * @return array An array containing the padding for all sides + */ + public function getRequiredPadding() + { + return array(10, 5, 15, 10); + } + + /** + * Render the horizontal axis + * + * @param RenderContext $ctx The context to use for rendering + * @param DOMElement $group The DOMElement this axis will be added to + */ + private function renderHorizontalAxis(RenderContext $ctx, DOMElement $group) + { + $steps = $this->ticksPerX($this->xUnit->getTicks(), $ctx->getNrOfUnitsX(), $this->minUnitsPerStep); + $ticks = $this->ticksPerX($this->xUnit->getTicks(), $ctx->getNrOfUnitsX(), $this->minUnitsPerTick); + + // Steps should always be ticks + if ($ticks !== $steps) { + $steps = $ticks * 5; + } + + // Check whether there is enough room for regular labels + $labelRotationStyle = $this->labelRotationStyle; + if ($this->labelsOversized($this->xUnit, 6)) { + $labelRotationStyle = self::LABEL_ROTATE_DIAGONAL; + } + + /* + $line = new Line(0, 100, 100, 100); + $line->setStrokeWidth(2); + $group->appendChild($line->toSvg($ctx)); + */ + + // contains the approximate end position of the last label + $lastLabelEnd = -1; + $shift = 0; + + $i = 0; + foreach ($this->xUnit as $label => $pos) { + if ($i % $ticks === 0) { + /* + $tick = new Line($pos, 100, $pos, 101); + $group->appendChild($tick->toSvg($ctx)); + */ + } + + if ($i % $steps === 0) { + if ($labelRotationStyle === self::LABEL_ROTATE_HORIZONTAL) { + // If the last label would overlap this label we shift the y axis a bit + if ($lastLabelEnd > $pos) { + $shift = ($shift + 5) % 10; + } else { + $shift = 0; + } + } + + $labelField = new Text($pos + 0.5, ($this->xLabel ? 107 : 105) + $shift, $label); + if ($labelRotationStyle === self::LABEL_ROTATE_HORIZONTAL) { + $labelField->setAlignment(Text::ALIGN_MIDDLE) + ->setFontSize('2.5em'); + } else { + $labelField->setFontSize('2.5em'); + } + + if ($labelRotationStyle === self::LABEL_ROTATE_DIAGONAL) { + $labelField = new Rotator($labelField, 45); + } + $labelField = $labelField->toSvg($ctx); + + $group->appendChild($labelField); + + if ($this->drawYGrid) { + $bgLine = new Line($pos, 0, $pos, 100); + $bgLine->setStrokeWidth(0.5) + ->setStrokeColor('#BFBFBF'); + $group->appendChild($bgLine->toSvg($ctx)); + } + $lastLabelEnd = $pos + strlen($label) * 1.2; + } + $i++; + } + } + + /** + * Render the vertical axis + * + * @param RenderContext $ctx The context to use for rendering + * @param DOMElement $group The DOMElement this axis will be added to + */ + private function renderVerticalAxis(RenderContext $ctx, DOMElement $group) + { + $steps = $this->ticksPerX($this->yUnit->getTicks(), $ctx->getNrOfUnitsY(), $this->minUnitsPerStep); + $ticks = $this->ticksPerX($this->yUnit->getTicks(), $ctx->getNrOfUnitsY(), $this->minUnitsPerTick); + + // Steps should always be ticks + if ($ticks !== $steps) { + $steps = $ticks * 5; + } + /* + $line = new Line(0, 0, 0, 100); + $line->setStrokeWidth(2); + $group->appendChild($line->toSvg($ctx)); + */ + + $i = 0; + foreach ($this->yUnit as $label => $pos) { + $pos = 100 - $pos; + + if ($i % $ticks === 0) { + // draw a tick + //$tick = new Line(0, $pos, -1, $pos); + //$group->appendChild($tick->toSvg($ctx)); + } + + if ($i % $steps === 0) { + // draw a step + $labelField = new Text(-0.5, $pos + 0.5, $label); + $labelField->setFontSize('2.5em') + ->setAlignment(Text::ALIGN_END); + + $group->appendChild($labelField->toSvg($ctx)); + if ($this->drawXGrid) { + $bgLine = new Line(0, $pos, 100, $pos); + $bgLine->setStrokeWidth(0.5) + ->setStrokeColor('#BFBFBF'); + $group->appendChild($bgLine->toSvg($ctx)); + } + } + $i++; + } + + if ($this->yLabel || $this->xLabel) { + if ($this->yLabel && $this->xLabel) { + $txt = $this->yLabel . ' / ' . $this->xLabel; + } elseif ($this->xLabel) { + $txt = $this->xLabel; + } else { + $txt = $this->yLabel; + } + + $axisLabel = new Text(50, -3, $txt); + $axisLabel->setFontSize('2em') + ->setFontWeight('bold') + ->setAlignment(Text::ALIGN_MIDDLE); + + $group->appendChild($axisLabel->toSvg($ctx)); + } + } + + /** + * Factory method, create an Axis instance using Linear ticks as the unit + * + * @return Axis The axis that has been created + * @see LinearUnit + */ + public static function createLinearAxis() + { + $axis = new Axis(); + $axis->setUnitForXAxis(self::linearUnit()); + $axis->setUnitForYAxis(self::linearUnit()); + return $axis; + } + + /** + * Set the label for the x axis + * + * An empty string means 'no label'. + * + * @param string $label The label to use for the x axis + * + * @return $this Fluid interface + */ + public function setXLabel($label) + { + $this->xLabel = $label; + return $this; + } + + /** + * Set the label for the y axis + * + * An empty string means 'no label'. + * + * @param string $label The label to use for the y axis + * + * @return $this Fluid interface + */ + public function setYLabel($label) + { + $this->yLabel = $label; + return $this; + } + + /** + * Set the labels minimum value for the x axis + * + * Setting the value to null let's the axis unit decide which value to use for the minimum + * + * @param int $xMin The minimum value to use for the x axis + * + * @return $this Fluid interface + */ + public function setXMin($xMin) + { + $this->xUnit->setMin($xMin); + return $this; + } + + /** + * Set the labels minimum value for the y axis + * + * Setting the value to null let's the axis unit decide which value to use for the minimum + * + * @param int $yMin The minimum value to use for the x axis + * + * @return $this Fluid interface + */ + public function setYMin($yMin) + { + $this->yUnit->setMin($yMin); + return $this; + } + + /** + * Set the labels maximum value for the x axis + * + * Setting the value to null let's the axis unit decide which value to use for the maximum + * + * @param int $xMax The minimum value to use for the x axis + * + * @return $this Fluid interface + */ + public function setXMax($xMax) + { + $this->xUnit->setMax($xMax); + return $this; + } + + /** + * Set the labels maximum value for the y axis + * + * Setting the value to null let's the axis unit decide which value to use for the maximum + * + * @param int $yMax The minimum value to use for the y axis + * + * @return $this Fluid interface + */ + public function setYMax($yMax) + { + $this->yUnit->setMax($yMax); + return $this; + } + + /** + * Transform all coordinates of the given dataset to coordinates that fit the graph's coordinate system + * + * @param array $dataSet The absolute coordinates as provided in the draw call + * + * @return array A graph relative representation of the given coordinates + */ + public function transform(array &$dataSet) + { + $result = array(); + foreach ($dataSet as &$points) { + $result[] = array( + $this->xUnit->transform($points[0]), + 100 - $this->yUnit->transform($points[1]) + ); + } + return $result; + } + + /** + * Create an AxisUnit that can be used in the axis to represent timestamps + * + * @return CalendarUnit + */ + public static function calendarUnit() + { + return new CalendarUnit(); + } + + /** + * Create an AxisUnit that can be used in the axis to represent a dataset as equally distributed + * ticks + * + * @param int $ticks + * @return LinearUnit + */ + public static function linearUnit($ticks = 10) + { + return new LinearUnit($ticks); + } + + /** + * Return the SVG representation of this object + * + * @param RenderContext $ctx The context to use for calculations + * + * @return DOMElement + * @see Drawable::toSvg + */ + public function toSvg(RenderContext $ctx) + { + $group = $ctx->getDocument()->createElement('g'); + $this->renderHorizontalAxis($ctx, $group); + $this->renderVerticalAxis($ctx, $group); + return $group; + } + + protected function ticksPerX($ticks, $units, $min) + { + $per = 1; + while ($per * $units / $ticks < $min) { + $per++; + } + return $per; + } + + /** + * Returns whether at least one label of the given Axis + * is bigger than the given maxLength + * + * @param AxisUnit $axis The axis that contains the labels that will be checked + * + * @return boolean Whether at least one label is bigger than maxLength + */ + private function labelsOversized(AxisUnit $axis, $maxLength = 5) + { + $oversized = false; + foreach ($axis as $label => $pos) { + if (strlen($label) > $maxLength) { + $oversized = true; + } + } + return $oversized; + } +} diff --git a/library/Icinga/Chart/Chart.php b/library/Icinga/Chart/Chart.php new file mode 100644 index 0000000..eaf69d1 --- /dev/null +++ b/library/Icinga/Chart/Chart.php @@ -0,0 +1,162 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use Imagick; +use Icinga\Chart\Legend; +use Icinga\Chart\Palette; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\SVGRenderer; +use Icinga\Exception\IcingaException; + +/** + * Base class for charts, extended by all other Chart classes. + */ +abstract class Chart implements Drawable +{ + protected $align = false; + + /** + * SVG renderer that handles + * + * @var SVGRenderer + */ + protected $renderer; + + /** + * Legend to use for this chart + * + * @var Legend + */ + protected $legend; + + /** + * The style-palette for this chart + * + * @var Palette + */ + protected $palette; + + /** + * The title of this chart, used for providing accessibility features + * + * @var string + */ + public $title; + + /** + * The description for this chart, mandatory for providing accessibility features + * + * @var string + */ + public $description; + + /** + * Create a new chart object and create internal objects + * + * If you want to extend this class use the init() method as an extension point, + * as this will be called at the end of the construct call + */ + public function __construct() + { + $this->legend = new Legend(); + $this->palette = new Palette(); + $this->init(); + } + + /** + * Extension point for subclasses, called on __construct + */ + protected function init() + { + } + + /** + * Extension point for implementing rendering logic + * + * This method is called after data validation, but before toSvg is called + */ + protected function build() + { + } + + /** + * Check if the current dataset has the proper structure for this chart. + * + * Needs to be overwritten by extending classes. The default implementation returns false. + * + * @return bool True when the dataset is valid, otherwise false + */ + abstract public function isValidDataFormat(); + + + /** + * Disable the legend for this chart + */ + public function disableLegend() + { + $this->legend = null; + } + + /** + * Render this graph and return the created SVG + * + * @return string The SVG created by the SvgRenderer + * + * @throws IcingaException Thrown wen the dataset is not valid for this graph + * @see SVGRenderer::render + */ + public function render() + { + if (!$this->isValidDataFormat()) { + throw new IcingaException('Dataset for graph doesn\'t have the proper structure'); + } + $this->build(); + if ($this->align) { + $this->renderer->preserveAspectRatio(); + $this->renderer->setXAspectRatioAlignment(SVGRenderer::X_ASPECT_RATIO_MIN); + $this->renderer->setYAspectRatioAlignment(SVGRenderer::Y_ASPECT_RATIO_MIN); + } + + $this->renderer->setAriaDescription($this->description); + $this->renderer->setAriaTitle($this->title); + $this->renderer->getCanvas()->setAriaRole('presentation'); + + $this->renderer->getCanvas()->addElement($this); + return $this->renderer->render(); + } + + /** + * Return this graph rendered as PNG + * + * @param int $width The width of the PNG in pixel + * @param int $height The height of the PNG in pixel + * + * @return string A PNG binary string + * + * @throws IcingaException In case ImageMagick is not available + */ + public function toPng($width, $height) + { + if (! class_exists('Imagick')) { + throw new IcingaException('Cannot render PNGs without ImageMagick'); + } + + $image = new Imagick(); + $image->readImageBlob($this->render()); + $image->setImageFormat('png24'); + $image->resizeImage($width, $height, imagick::FILTER_LANCZOS, 1); + return $image; + } + + /** + * Align the chart to the top left corner instead of centering it + * + * @param bool $align + */ + public function alignTopLeft($align = true) + { + $this->align = $align; + } +} 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); + } +} diff --git a/library/Icinga/Chart/Format.php b/library/Icinga/Chart/Format.php new file mode 100644 index 0000000..9e6c4db --- /dev/null +++ b/library/Icinga/Chart/Format.php @@ -0,0 +1,21 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +class Format +{ + /** + * Format a number into a number-string as defined by the SVG-Standard + * + * @see http://www.w3.org/TR/SVG/types.html#DataTypeNumber + * + * @param $number + * + * @return string + */ + public static function formatSVGNumber($number) + { + return number_format($number, 1, '.', ''); + } +} diff --git a/library/Icinga/Chart/Graph/BarGraph.php b/library/Icinga/Chart/Graph/BarGraph.php new file mode 100644 index 0000000..adef428 --- /dev/null +++ b/library/Icinga/Chart/Graph/BarGraph.php @@ -0,0 +1,162 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Graph; + +use DOMElement; +use Icinga\Chart\Primitive\Animation; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Primitive\Rect; +use Icinga\Chart\Primitive\Styleable; +use Icinga\Chart\Render\RenderContext; + +/** + * Bar graph implementation + */ +class BarGraph extends Styleable implements Drawable +{ + /** + * The dataset order + * + * @var int + */ + private $order = 0; + + /** + * The width of the bars. + * + * @var int + */ + private $barWidth = 3; + + /** + * The dataset to use for this bar graph + * + * @var array + */ + 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 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, + array &$graphs, + $order, + array $tooltips = null + ) { + $this->order = $order; + $this->dataSet = $dataSet; + + $this->tooltips = $tooltips; + foreach ($this->tooltips as $value) { + $ts[] = $value; + } + $this->tooltips = $ts; + + $this->graphs = $graphs; + } + + /** + * Apply configuration styles from the $cfg + * + * @param array $cfg The configuration as given in the drawBars call + */ + public function setStyleFromConfig(array $cfg) + { + foreach ($cfg as $elem => $value) { + if ($elem === 'color') { + $this->setFill($value); + } elseif ($elem === 'width') { + $this->setStrokeWidth($value); + } + } + } + + /** + * Draw a single rectangle + * + * @param array $point The + * @param string $fill The fill color to use + * @param $strokeWidth + * @param null $index + * + * @return Rect + */ + private function drawSingleBar($point, $fill, $strokeWidth, $index = null) + { + $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 + * + * @param RenderContext $ctx The rendering context to use for drawing + * + * @return DOMElement $dom Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + $group = $doc->createElement('g'); + $idx = 0; + + if (count($this->dataSet) > 15) { + $this->barWidth = 2; + } + if (count($this->dataSet) > 25) { + $this->barWidth = 1; + } + + foreach ($this->dataSet as $x => $point) { + // add white background bar, to prevent other bars from altering transparency effects + $bar = $this->drawSingleBar($point, 'white', $this->strokeWidth, $idx++)->toSvg($ctx); + $group->appendChild($bar); + + // draw actual bar + $bar = $this->drawSingleBar($point, $this->fill, $this->strokeWidth)->toSvg($ctx); + 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; + $title = $ctx->getDocument()->createElement('title'); + $title->textContent = $this->tooltips[$x]->renderNoHtml($this->order, $data, $format); + $bar->appendChild($title); + } + $group->appendChild($bar); + } + return $group; + } +} diff --git a/library/Icinga/Chart/Graph/LineGraph.php b/library/Icinga/Chart/Graph/LineGraph.php new file mode 100644 index 0000000..6954c59 --- /dev/null +++ b/library/Icinga/Chart/Graph/LineGraph.php @@ -0,0 +1,195 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Graph; + +use DOMElement; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Primitive\Path; +use Icinga\Chart\Primitive\Circle; +use Icinga\Chart\Primitive\Styleable; +use Icinga\Chart\Render\RenderContext; + +/** + * LineGraph implementation for drawing a set of datapoints as + * a connected path + */ +class LineGraph extends Styleable implements Drawable +{ + /** + * The dataset to use + * + * @var array + */ + private $dataset; + + /** + * True to show dots for each datapoint + * + * @var bool + */ + private $showDataPoints = false; + + /** + * When true, the path will be discrete, i.e. showing hard steps instead of a direct line + * + * @var bool + */ + private $isDiscrete = false; + + /** + * The tooltips + * + * @var + */ + private $tooltips; + + /** + * The default stroke width + * @var int + */ + public $strokeWidth = 5; + + /** + * The size of the displayed dots + * + * @var int + */ + public $dotWith = 0; + + /** + * Create a new LineGraph displaying the given dataset + * + * @param array $dataset An array of [x, y] arrays to display + */ + public function __construct( + array $dataset, + array &$graphs, + $order, + array $tooltips = null + ) { + usort($dataset, array($this, 'sortByX')); + $this->dataset = $dataset; + $this->graphs = $graphs; + + $this->tooltips = $tooltips; + foreach ($this->tooltips as $value) { + $ts[] = $value; + } + $this->tooltips = $ts; + $this->order = $order; + } + + /** + * Set datapoints to be emphased via dots + * + * @param bool $bool True to enable datapoints, otherwise false + */ + public function setShowDataPoints($bool) + { + $this->showDataPoints = $bool; + } + + /** + * Sort the daset by the xaxis + * + * @param array $v1 + * @param array $v2 + * @return int + */ + private function sortByX(array $v1, array $v2) + { + if ($v1[0] === $v2[0]) { + return 0; + } + return ($v1[0] < $v2[0]) ? -1 : 1; + } + + /** + * Configure this style + * + * @param array $cfg The configuration as given in the drawLine call + */ + public function setStyleFromConfig(array $cfg) + { + $fill = false; + foreach ($cfg as $elem => $value) { + if ($elem === 'color') { + $this->setStrokeColor($value); + } elseif ($elem === 'width') { + $this->setStrokeWidth($value); + } elseif ($elem === 'showPoints') { + $this->setShowDataPoints($value); + } elseif ($elem === 'fill') { + $fill = $value; + } elseif ($elem === 'discrete') { + $this->isDiscrete = true; + } + } + if ($fill) { + $this->setFill($this->strokeColor); + $this->setStrokeColor('black'); + } + } + + /** + * Render this BarChart + * + * @param RenderContext $ctx The rendering context to use for drawing + * + * @return DOMElement $dom Element + */ + public function toSvg(RenderContext $ctx) + { + $path = new Path($this->dataset); + if ($this->isDiscrete) { + $path->setDiscrete(true); + } + $path->setStrokeColor($this->strokeColor); + $path->setStrokeWidth($this->strokeWidth); + + $path->setAttribute('data-icinga-graph-type', 'line'); + if ($this->fill !== 'none') { + $firstX = $this->dataset[0][0]; + $lastX = $this->dataset[count($this->dataset)-1][0]; + $path->prepend(array($firstX, 100)) + ->append(array($lastX, 100)); + $path->setFill($this->fill); + } + + $path->setAdditionalStyle('clip-path: url(#clip);'); + $path->setId($this->id); + $group = $path->toSvg($ctx); + + foreach ($this->dataset as $x => $point) { + if ($this->showDataPoints === true) { + $dot = new Circle($point[0], $point[1], $this->dotWith); + $dot->setFill($this->strokeColor); + $group->appendChild($dot->toSvg($ctx)); + } + + // Draw invisible circle for tooltip hovering + if (isset($this->tooltips[$x])) { + $invisible = new Circle($point[0], $point[1], 20); + $invisible->setFill($this->strokeColor); + $invisible->setAdditionalStyle('opacity: 0.0;'); + $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; + $title = $ctx->getDocument()->createElement('title'); + $title->textContent = $this->tooltips[$x]->renderNoHtml($this->order, $data, $format); + $invisibleRendered = $invisible->toSvg($ctx); + $invisibleRendered->appendChild($title); + $group->appendChild($invisibleRendered); + } + } + + return $group; + } +} diff --git a/library/Icinga/Chart/Graph/StackedGraph.php b/library/Icinga/Chart/Graph/StackedGraph.php new file mode 100644 index 0000000..49801a9 --- /dev/null +++ b/library/Icinga/Chart/Graph/StackedGraph.php @@ -0,0 +1,88 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Graph; + +use DOMElement; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Render\RenderContext; + +/** + * Graph implementation that stacks several graphs and displays them in a cumulative way + */ +class StackedGraph implements Drawable +{ + /** + * All graphs displayed in this stackedgraph + * + * @var array + */ + private $stack = array(); + + /** + * An associative array containing x points as the key and an array of y values as the value + * + * @var array + */ + private $points = array(); + + /** + * Add a graph to this stack and aggregate the values on the fly + * + * This modifies the dataset as a side effect + * + * @param array $subGraph + */ + public function addGraph(array &$subGraph) + { + foreach ($subGraph['data'] as &$point) { + $x = $point[0]; + 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]; + } + } + + /** + * Add a graph to the stack + * + * @param $graph + */ + public function addToStack($graph) + { + $this->stack[] = $graph; + } + + /** + * Empty the stack + * + * @return bool + */ + public function stackEmpty() + { + return empty($this->stack); + } + + /** + * Render this stack in the correct order + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG representation of this graph + */ + public function toSvg(RenderContext $ctx) + { + $group = $ctx->getDocument()->createElement('g'); + $renderOrder = array_reverse($this->stack); + foreach ($renderOrder as $stackElem) { + $group->appendChild($stackElem->toSvg($ctx)); + } + return $group; + } +} diff --git a/library/Icinga/Chart/Graph/Tooltip.php b/library/Icinga/Chart/Graph/Tooltip.php new file mode 100644 index 0000000..7236685 --- /dev/null +++ b/library/Icinga/Chart/Graph/Tooltip.php @@ -0,0 +1,143 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +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>: {value} {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)); + } +} diff --git a/library/Icinga/Chart/GridChart.php b/library/Icinga/Chart/GridChart.php new file mode 100644 index 0000000..a8cfca6 --- /dev/null +++ b/library/Icinga/Chart/GridChart.php @@ -0,0 +1,446 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMElement; +use Icinga\Chart\Chart; +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; +use Icinga\Chart\Render\LayoutBox; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Unit\AxisUnit; + +/** + * Base class for grid based charts. + * + * Allows drawing of Line and Barcharts. See the graphing documentation for further details. + * + * Example: + * <pre> + * <code> + * $this->chart = new GridChart(); + * $this->chart->setAxisLabel("X axis label", "Y axis label"); + * $this->chart->setXAxis(Axis::CalendarUnit()); + * $this->chart->drawLines( + * array( + * 'data' => array( + * array(time()-7200, 10),array(time()-3620, 30), array(time()-1800, 15), array(time(), 92)) + * ) + * ); + * </code> + * </pre> + */ +class GridChart extends Chart +{ + /** + * Internal identifier for Line Chart elements + */ + const TYPE_LINE = "LINE"; + + /** + * Internal identifier fo Bar Chart elements + */ + const TYPE_BAR = "BAR"; + + /** + * Internal array containing all elements to be drawn in the order they are drawn + * + * @var array + */ + private $graphs = array(); + + /** + * An associative array containing all axis of this Chart in the "name" => Axis() form. + * + * Currently only the 'default' axis is really supported + * + * @var array + */ + private $axis = array(); + + /** + * An associative array containing all StackedGraph objects used for cumulative graphs + * + * The array key is the 'stack' value given in the graph definitions + * + * @var array + */ + 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(); + + public function __construct() + { + $this->title = t('Grid Chart'); + $this->description = t('Contains data in a bar or line chart.'); + parent::__construct(); + } + + /** + * Check if the current dataset has the proper structure for this chart. + * + * Needs to be overwritten by extending classes. The default implementation returns false. + * + * @return bool True when the dataset is valid, otherwise false + */ + public function isValidDataFormat() + { + foreach ($this->graphs as $values) { + foreach ($values as $value) { + if (!isset($value['data']) || !is_array($value['data'])) { + return false; + } + } + } + return true; + } + + /** + * Calls Axis::addDataset for every graph added to this GridChart + * + * @see Axis::addDataset + */ + private function configureAxisFromDatasets() + { + foreach ($this->graphs as $axis => &$graphs) { + $axisObj = $this->axis[$axis]; + foreach ($graphs as &$graph) { + $axisObj->addDataset($graph); + } + } + } + + /** + * Add an arbitrary number of lines to be drawn + * + * Refer to the graphs.md for a detailed list of allowed attributes + * + * @param array $axis,... The line definitions to draw + * + * @return $this Fluid interface + */ + public function drawLines(array $axis) + { + $this->draw(self::TYPE_LINE, func_get_args()); + return $this; + } + + /** + * Add arbitrary number of bars to be drawn + * + * Refer to the graphs.md for a detailed list of allowed attributes + * + * @param array $axis + * @return $this + */ + public function drawBars(array $axis) + { + $this->draw(self::TYPE_BAR, func_get_args()); + return $this; + } + + /** + * Generic method for adding elements to the drawing stack + * + * @param string $type The type of the element to draw (see TYPE_ constants in this class) + * @param array $data The data given to the draw call + */ + private function draw($type, $data) + { + $axisName = 'default'; + if (is_string($data[0])) { + $axisName = $data[0]; + array_shift($data); + } + foreach ($data as &$graph) { + $graph['graphType'] = $type; + if (isset($graph['stack'])) { + if (!isset($this->stacks[$graph['stack']])) { + $this->stacks[$graph['stack']] = new StackedGraph(); + } + $this->stacks[$graph['stack']]->addGraph($graph); + $graph['stack'] = $this->stacks[$graph['stack']]; + } + + if (!isset($graph['color'])) { + $colorType = isset($graph['palette']) ? $graph['palette'] : Palette::NEUTRAL; + $graph['color'] = $this->palette->getNext($colorType); + } + $this->graphs[$axisName][] = $graph; + if ($this->legend) { + $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); + } + } + } + + /** + * Set the label for the x and y axis + * + * @param string $xAxisLabel The label to use for the x axis + * @param string $yAxisLabel The label to use for the y axis + * @param string $axisName The name of the axis, for now 'default' + * + * @return $this Fluid interface + */ + public function setAxisLabel($xAxisLabel, $yAxisLabel, $axisName = 'default') + { + $this->axis[$axisName]->setXLabel($xAxisLabel)->setYLabel($yAxisLabel); + return $this; + } + + /** + * Set the AxisUnit to use for calculating the values of the x axis + * + * @param AxisUnit $unit The unit for the x axis + * @param string $axisName The name of the axis to set the label for, currently only 'default' + * + * @return $this Fluid interface + */ + public function setXAxis(AxisUnit $unit, $axisName = 'default') + { + $this->axis[$axisName]->setUnitForXAxis($unit); + return $this; + } + + /** + * Set the AxisUnit to use for calculating the values of the y axis + * + * @param AxisUnit $unit The unit for the y axis + * @param string $axisName The name of the axis to set the label for, currently only 'default' + * + * @return $this Fluid interface + */ + public function setYAxis(AxisUnit $unit, $axisName = 'default') + { + $this->axis[$axisName]->setUnitForYAxis($unit); + return $this; + } + + /** + * Pre-render setup of the axis + * + * @see Chart::build + */ + protected function build() + { + $this->configureAxisFromDatasets(); + } + + /** + * Initialize the renderer and overwrite it with an 2:1 ration renderer + */ + protected function init() + { + $this->renderer = new SVGRenderer(100, 100); + $this->setAxis(Axis::createLinearAxis()); + } + + /** + * Overwrite the axis to use + * + * @param Axis $axis The new axis to use + * @param string $name The name of the axis, currently only 'default' + * + * @return $this Fluid interface + */ + public function setAxis(Axis $axis, $name = 'default') + { + $this->axis = array($name => $axis); + return $this; + } + + /** + * Add an axis to this graph (not really supported right now) + * + * @param Axis $axis The axis object to add + * @param string $name The name of the axis + * + * @return $this Fluid interface + */ + public function addAxis(Axis $axis, $name) + { + $this->axis[$name] = $axis; + return $this; + } + + /** + * Set minimum values for the x and y axis. + * + * Setting null to an axis means this will use a value determined by the dataset + * + * @param int $xMin The minimum value for the x axis or null to use a dynamic value + * @param int $yMin The minimum value for the y axis or null to use a dynamic value + * @param string $axisName The name of the axis to set the minimum, currently only 'default' + * + * @return $this Fluid interface + */ + public function setAxisMin($xMin = null, $yMin = null, $axisName = 'default') + { + $this->axis[$axisName]->setXMin($xMin)->setYMin($yMin); + return $this; + } + + /** + * Set maximum values for the x and y axis. + * + * Setting null to an axis means this will use a value determined by the dataset + * + * @param int $xMax The maximum value for the x axis or null to use a dynamic value + * @param int $yMax The maximum value for the y axis or null to use a dynamic value + * @param string $axisName The name of the axis to set the maximum, currently only 'default' + * + * @return $this Fluid interface + */ + public function setAxisMax($xMax = null, $yMax = null, $axisName = 'default') + { + $this->axis[$axisName]->setXMax($xMax)->setYMax($yMax); + return $this; + } + + /** + * Render this GridChart to SVG + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement + */ + public function toSvg(RenderContext $ctx) + { + $outerBox = new Canvas('outerGraph', new LayoutBox(0, 0, 100, 100)); + $innerBox = new Canvas('graph', new LayoutBox(0, 0, 95, 90)); + + $maxPadding = array(0,0,0,0); + foreach ($this->axis as $axis) { + $padding = $axis->getRequiredPadding(); + for ($i=0; $i < count($padding); $i++) { + $maxPadding[$i] = max($maxPadding[$i], $padding[$i]); + } + $innerBox->addElement($axis); + } + $this->renderGraphContent($innerBox); + + $innerBox->getLayout()->setPadding($maxPadding[0], $maxPadding[1], $maxPadding[2], $maxPadding[3]); + $this->createContentClipBox($innerBox); + + $outerBox->addElement($innerBox); + if ($this->legend) { + $outerBox->addElement($this->legend); + } + return $outerBox->toSvg($ctx); + } + + /** + * Create a clip box that defines which area of the graph is drawable and adds it to the graph. + * + * The clipbox has the id '#clip' and can be used in the clip-mask element + * + * @param Canvas $innerBox The inner canvas of the graph to add the clip box to + */ + private function createContentClipBox(Canvas $innerBox) + { + $clipBox = new Canvas('clip', new LayoutBox(0, 0, 100, 100)); + $clipBox->toClipPath(); + $innerBox->addElement($clipBox); + $rect = new Rect(0.1, 0, 100, 99.9); + $clipBox->addElement($rect); + } + + /** + * Render the content of the graph, i.e. the draw stack + * + * @param Canvas $innerBox The inner canvas of the graph to add the content to + */ + private function renderGraphContent(Canvas $innerBox) + { + foreach ($this->graphs as $axisName => $graphs) { + $axis = $this->axis[$axisName]; + $graphObj = null; + 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']), + $graphs, + $dataset, + $this->tooltips + ); + break; + case self::TYPE_LINE: + $graphObj = new LineGraph( + $axis->transform($graph['data']), + $graphs, + $dataset, + $this->tooltips + ); + break; + default: + continue 2; + } + $el = $this->setupGraph($graphObj, $graph); + if ($el) { + $innerBox->addElement($el); + } + } + } + } + + /** + * Setup the provided Graph type + * + * @param mixed $graphObject The graph class, needs the setStyleFromConfig method + * @param array $graphConfig The configration array of the graph + * + * @return mixed Either the graph to be added or null if the graph is not directly added + * to the document (e.g. stacked graphs are added by + * the StackedGraph Composite object) + */ + private function setupGraph($graphObject, array $graphConfig) + { + $graphObject->setStyleFromConfig($graphConfig); + // When in a stack return the StackedGraph object instead of the graphObject + if (isset($graphConfig['stack'])) { + $graphConfig['stack']->addToStack($graphObject); + if (!$graphConfig['stack']->stackEmpty()) { + return $graphConfig['stack']; + } + // return no object when the graph should not be rendered + return null; + } + return $graphObject; + } +} diff --git a/library/Icinga/Chart/Inline/Inline.php b/library/Icinga/Chart/Inline/Inline.php new file mode 100644 index 0000000..3acbd73 --- /dev/null +++ b/library/Icinga/Chart/Inline/Inline.php @@ -0,0 +1,96 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Inline; + +/** + * Class to render and inline chart directly from the request params. + * + * When rendering huge amounts of inline charts it is too expensive + * to bootstrap the complete application for ever single chart and + * we need to be able render Charts in a compact environment without + * the other Icinga classes. + * + * Class Inline + * @package Icinga\Chart\Inline + */ +class Inline +{ + + /** + * The data displayed in this chart + * + * @var array + */ + protected $data; + + /** + * The colors used to display this chart + * + * @var array + */ + protected $colors = array( + '#00FF00', // OK + '#FFFF00', // Warning + '#FF0000', // Critical + '#E066FF' // Unreachable + ); + + /** + * The labels displayed on this chart + * + * @var array + */ + protected $labels = array(); + + /** + * The height in percent + * + * @var int + */ + protected $height = 100; + + /** + * The width in percent + * + * @var int + */ + protected $width = 100; + + protected function sanitizeStringArray(array $arr) + { + $sanitized = array(); + foreach ($arr as $key => $value) { + $sanitized[$key] = htmlspecialchars($value); + } + return $sanitized; + } + + /** + * Populate the properties from the current request. + */ + public function initFromRequest() + { + $this->data = explode(',', $_GET['data']); + foreach ($this->data as $key => $value) { + $this->data[$key] = (int)$value; + } + for ($i = 0; $i < count($this->data); $i++) { + $this->labels[] = ''; + } + + if (array_key_exists('colors', $_GET)) { + $this->colors = $this->sanitizeStringArray(explode(',', $_GET['colors'])); + } + while (count($this->colors) < count($this->data)) { + $this->colors[] = '#FEFEFE'; + } + + if (array_key_exists('width', $_GET)) { + $this->width = (int)$_GET['width']; + } + if (array_key_exists('height', $_GET)) { + $this->height = (int)$_GET['height']; + } + } +} diff --git a/library/Icinga/Chart/Inline/PieChart.php b/library/Icinga/Chart/Inline/PieChart.php new file mode 100644 index 0000000..de68213 --- /dev/null +++ b/library/Icinga/Chart/Inline/PieChart.php @@ -0,0 +1,41 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Inline; + +use Icinga\Chart\PieChart as PieChartRenderer; + +/** + * Draw an inline pie-chart directly from the available request parameters. + */ +class PieChart extends Inline +{ + protected function getChart() + { + $pie = new PieChartRenderer(); + $pie->alignTopLeft(); + $pie->disableLegend(); + $pie->drawPie(array( + 'data' => $this->data, 'colors' => $this->colors, 'labels' => $this->labels + )); + return $pie; + } + + public function toSvg($output = true) + { + if ($output) { + echo $this->getChart()->render(); + } else { + return $this->getChart()->render(); + } + } + + public function toPng($output = true) + { + if ($output) { + echo $this->getChart()->toPng($this->width, $this->height); + } else { + return $this->getChart()->toPng($this->width, $this->height); + } + } +} diff --git a/library/Icinga/Chart/Legend.php b/library/Icinga/Chart/Legend.php new file mode 100644 index 0000000..ab1c9e0 --- /dev/null +++ b/library/Icinga/Chart/Legend.php @@ -0,0 +1,102 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMElement; +use Icinga\Chart\Palette; +use Icinga\Chart\Primitive\Canvas; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Primitive\Rect; +use Icinga\Chart\Primitive\Text; +use Icinga\Chart\Render\LayoutBox; +use Icinga\Chart\Render\RenderContext; + +/** + * Drawable for creating a Graph Legend on the bottom of a graph. + * + * Usually used by the GridChart class internally. + */ +class Legend implements Drawable +{ + + /** + * Internal counter for unnamed label identifiers + * + * @var int + */ + private $internalCtr = 0; + + /** + * + * Content of this legend + * + * @var array + */ + private $dataset = array(); + + + /** + * Set the content to be displayed by this legend + * + * @param array $dataset An array of datasets in the form they are provided to the graphing implementation + */ + public function addDataset(array $dataset) + { + if (!isset($dataset['label'])) { + $dataset['label'] = 'Dataset ' . (++$this->internalCtr); + } + if (!isset($dataset['color'])) { + return; + } + $this->dataset[$dataset['color']] = $dataset['label']; + } + + /** + * Render the legend to an SVG object + * + * @param RenderContext $ctx The context to use for rendering this legend + * + * @return DOMElement The SVG representation of this legend + */ + public function toSvg(RenderContext $ctx) + { + $outer = new Canvas('legend', new LayoutBox(0, 0, 100, 100)); + $outer->getLayout()->setPadding(2, 2, 2, 2); + $nrOfColumns = 4; + + $topstep = 10 / $nrOfColumns + 2; + + $top = 0; + $left = 0; + $lastLabelEndPos = -1; + foreach ($this->dataset as $color => $text) { + $leftstep = 100 / $nrOfColumns + strlen($text); + + // Make sure labels don't overlap each other + while ($lastLabelEndPos >= $left) { + $left += $leftstep; + } + // When a label is longer than the available space, use the next line + if ($left + strlen($text) > 100) { + $top += $topstep; + $left = 0; + } + + $colorBox = new Rect($left, $top, 2, 2); + $colorBox->setFill($color)->setStrokeWidth(2); + $colorBox->keepRatio(); + $outer->addElement($colorBox); + + $textBox = new Text($left+5, $top+2, $text); + $textBox->setFontSize('2em'); + $outer->addElement($textBox); + + // readjust layout + $lastLabelEndPos = $left + strlen($text); + $left += $leftstep; + } + $svg = $outer->toSvg($ctx); + return $svg; + } +} diff --git a/library/Icinga/Chart/Palette.php b/library/Icinga/Chart/Palette.php new file mode 100644 index 0000000..90ad74b --- /dev/null +++ b/library/Icinga/Chart/Palette.php @@ -0,0 +1,65 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +/** + * Provide a set of colors that will be used by the chart as default values + */ +class Palette +{ + /** + * Neutral colors without special meaning + */ + const NEUTRAL = 'neutral'; + + /** + * A set of problem (i.e. red) colors + */ + const PROBLEM = 'problem'; + + /** + * A set of ok (i.e. green) colors + */ + const OK = 'ok'; + + /** + * A set of warning (i.e. yellow) colors + */ + const WARNING = 'warning'; + + /** + * The colorsets for specific categories + * + * @var array + */ + public $colorSets = array( + self::OK => array('#00FF00'), + self::PROBLEM => array('#FF0000'), + self::WARNING => array('#FFFF00'), + self::NEUTRAL => array('#f3f3f3') + ); + + /** + * Return the next available color as an hex string for the given type + * + * @param string $type The type to receive a color from + * + * @return string The color in hex format + */ + public function getNext($type = self::NEUTRAL) + { + if (!isset($this->colorSets[$type])) { + $type = self::NEUTRAL; + } + + $color = current($this->colorSets[$type]); + if ($color === false) { + reset($this->colorSets[$type]); + + $color = current($this->colorSets[$type]); + } + next($this->colorSets[$type]); + return $color; + } +} diff --git a/library/Icinga/Chart/PieChart.php b/library/Icinga/Chart/PieChart.php new file mode 100644 index 0000000..1bcf380 --- /dev/null +++ b/library/Icinga/Chart/PieChart.php @@ -0,0 +1,306 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMElement; +use Icinga\Chart\Chart; +use Icinga\Chart\Primitive\Canvas; +use Icinga\Chart\Primitive\PieSlice; +use Icinga\Chart\Primitive\RawElement; +use Icinga\Chart\Primitive\Rect; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Render\LayoutBox; + +/** + * Graphing component for rendering Pie Charts. + * + * See the graphs.md documentation for further information about how to use this component + */ +class PieChart extends Chart +{ + /** + * Stack multiple pies + */ + const STACKED = "stacked"; + + /** + * Draw multiple pies beneath each other + */ + const ROW = "row"; + + /** + * The drawing stack containing all pie definitions in the order they will be drawn + * + * @var array + */ + private $pies = array(); + + /** + * The composition type currently used + * + * @var string + */ + private $type = PieChart::STACKED; + + /** + * Disable drawing of captions when set true + * + * @var bool + */ + private $noCaption = false; + + public function __construct() + { + $this->title = t('Pie Chart'); + $this->description = t('Contains data in a pie chart.'); + parent::__construct(); + } + + /** + * Test if the given pies have the correct format + * + * @return bool True when the given pies are correct, otherwise false + */ + public function isValidDataFormat() + { + foreach ($this->pies as $pie) { + if (!isset($pie['data']) || !is_array($pie['data'])) { + return false; + } + } + return true; + } + + /** + * Create renderer and normalize the dataset to represent percentage information + */ + protected function build() + { + $this->renderer = new SVGRenderer(($this->type === self::STACKED) ? 1 : count($this->pies), 1); + foreach ($this->pies as &$pie) { + $this->normalizeDataSet($pie); + } + } + + /** + * Normalize the given dataset to represent percentage information instead of absolute valuess + * + * @param array $pie The pie definition given in the drawPie call + */ + private function normalizeDataSet(&$pie) + { + $total = array_sum($pie['data']); + if ($total === 100) { + return; + } + if ($total == 0) { + return; + } + foreach ($pie['data'] as &$slice) { + $slice = $slice/$total * 100; + } + } + + /** + * Draw an arbitrary number of pies in this chart + * + * @param array $dataSet,... The pie definition, see graphs.md for further details concerning the format + * + * @return $this Fluent interface + */ + public function drawPie(array $dataSet) + { + $dataSets = func_get_args(); + $this->pies += $dataSets; + foreach ($dataSets as $dataSet) { + $this->legend->addDataset($dataSet); + } + return $this; + } + + /** + * Return the SVG representation of this graph + * + * @param RenderContext $ctx The context to use for drawings + * + * @return DOMElement The SVG representation of this graph + */ + public function toSvg(RenderContext $ctx) + { + $labelBox = $ctx->getDocument()->createElement('g'); + if (!$this->noCaption) { + // Scale SVG to make room for captions + $outerBox = new Canvas('outerGraph', new LayoutBox(33, -5, 40, 40)); + $innerBox = new Canvas('graph', new LayoutBox(0, 0, 100, 100)); + $innerBox->getLayout()->setPadding(10, 10, 10, 10); + } else { + $outerBox = new Canvas('outerGraph', new LayoutBox(1.5, -10, 124, 124)); + $innerBox = new Canvas('graph', new LayoutBox(0, 0, 100, 100)); + $innerBox->getLayout()->setPadding(0, 0, 0, 0); + } + $this->createContentClipBox($innerBox); + $this->renderPies($innerBox, $labelBox); + $innerBox->addElement(new RawElement($labelBox)); + $outerBox->addElement($innerBox); + + return $outerBox->toSvg($ctx); + } + + /** + * Render the pies in the draw stack using the selected algorithm for composition + * + * @param Canvas $innerBox The canvas to use for inserting the pies + * @param DOMElement $labelBox The DOM element to add the labels to (so they can't be overlapped by pie elements) + */ + private function renderPies(Canvas $innerBox, DOMElement $labelBox) + { + if ($this->type === self::STACKED) { + $this->renderStackedPie($innerBox, $labelBox); + } else { + $this->renderPieRow($innerBox, $labelBox); + } + } + + /** + * Return the color to be used for the given pie slice + * + * @param array $pie The pie configuration as provided in the drawPie call + * @param int $dataIdx The index of the pie slice in the pie configuration + * + * @return string The hex color string to use for the pie slice + */ + private function getColorForPieSlice(array $pie, $dataIdx) + { + if (isset($pie['colors']) && is_array($pie['colors']) && isset($pie['colors'][$dataIdx])) { + return $pie['colors'][$dataIdx]; + } + $type = Palette::NEUTRAL; + if (isset($pie['palette']) && is_array($pie['palette']) && isset($pie['palette'][$dataIdx])) { + $type = $pie['palette'][$dataIdx]; + } + return $this->palette->getNext($type); + } + + /** + * Render a row of pies + * + * @param Canvas $innerBox The canvas to insert the pies to + * @param DOMElement $labelBox The DOMElement to use for adding label elements + */ + private function renderPieRow(Canvas $innerBox, DOMElement $labelBox) + { + $radius = 50 / count($this->pies); + $x = $radius; + foreach ($this->pies as $pie) { + $labelPos = 0; + $lastRadius = 0; + + foreach ($pie['data'] as $idx => $dataset) { + $slice = new PieSlice($radius, $dataset, $lastRadius); + $slice->setX($x) + ->setStrokeColor('#000') + ->setStrokeWidth(1) + ->setY(50) + ->setFill($this->getColorForPieSlice($pie, $idx)); + $innerBox->addElement($slice); + // add caption if not disabled + if (!$this->noCaption && isset($pie['labels'])) { + $slice->setCaption($pie['labels'][$labelPos++]) + ->setLabelGroup($labelBox); + } + $lastRadius += $dataset; + } + // shift right for next pie + $x += $radius*2; + } + } + + /** + * Render pies in a stacked way so one pie is nested in the previous pie + * + * @param Canvas $innerBox The canvas to insert the pie to + * @param DOMElement $labelBox The DOMElement to use for adding label elements + */ + private function renderStackedPie(Canvas $innerBox, DOMElement $labelBox) + { + $radius = 40; + $minRadius = 20; + if (count($this->pies) == 0) { + return; + } + $shrinkStep = ($radius - $minRadius) / count($this->pies); + $x = $radius; + + for ($i = 0; $i < count($this->pies); $i++) { + $pie = $this->pies[$i]; + // the offset for the caption path, outer caption indicator shouldn't point + // to the middle of the slice as there will be another pie + $offset = isset($this->pies[$i+1]) ? $radius - $shrinkStep : 0; + $labelPos = 0; + $lastRadius = 0; + foreach ($pie['data'] as $idx => $dataset) { + $color = $this->getColorForPieSlice($pie, $idx); + if ($dataset == 0) { + $labelPos++; + continue; + } + $slice = new PieSlice($radius, $dataset, $lastRadius); + $slice->setY(50) + ->setX($x) + ->setStrokeColor('#000') + ->setStrokeWidth(1) + ->setFill($color) + ->setLabelGroup($labelBox); + + if (!$this->noCaption && isset($pie['labels'])) { + $slice->setCaption($pie['labels'][$labelPos++]) + ->setCaptionOffset($offset) + ->setOuterCaptionBound(50); + } + $innerBox->addElement($slice); + $lastRadius += $dataset; + } + // shrinken the next pie + $radius -= $shrinkStep; + } + } + + /** + * Set the composition type of this PieChart + * + * @param string $type Either self::STACKED or self::ROW + * + * @return $this Fluent interface + */ + public function setType($type) + { + $this->type = $type; + return $this; + } + + /** + * Hide the caption from this PieChart + * + * @return $this Fluent interface + */ + public function disableLegend() + { + $this->noCaption = true; + return $this; + } + + /** + * Create the content for this PieChart + * + * @param Canvas $innerBox The innerbox to add the clip mask to + */ + private function createContentClipBox(Canvas $innerBox) + { + $clipBox = new Canvas('clip', new LayoutBox(0, 0, 100, 100)); + $clipBox->toClipPath(); + $innerBox->addElement($clipBox); + $rect = new Rect(0.1, 0, 100, 99.9); + $clipBox->addElement($rect); + } +} diff --git a/library/Icinga/Chart/Primitive/Animatable.php b/library/Icinga/Chart/Primitive/Animatable.php new file mode 100644 index 0000000..69ba0e1 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Animatable.php @@ -0,0 +1,43 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; + +/** + * Base interface for animatable objects + */ +abstract class Animatable extends Styleable +{ + /** + * The animation object set + * + * @var Animation + */ + public $animation = null; + + /** + * Set the animation for this object + * + * @param Animation $anim The animation to use + */ + public function setAnimation(Animation $anim) + { + $this->animation = $anim; + } + + /** + * Append the animation to the given element + * + * @param DOMElement $dom The element to append the animation to + * @param RenderContext $ctx The context to use for rendering the animation object + */ + protected function appendAnimation(DOMElement $dom, RenderContext $ctx) + { + if ($this->animation) { + $dom->appendChild($this->animation->toSvg($ctx)); + } + } +} diff --git a/library/Icinga/Chart/Primitive/Animation.php b/library/Icinga/Chart/Primitive/Animation.php new file mode 100644 index 0000000..4e55d0e --- /dev/null +++ b/library/Icinga/Chart/Primitive/Animation.php @@ -0,0 +1,87 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; + +/** + * Drawable for the SVG animate tag + */ +class Animation implements Drawable +{ + /** + * The attribute to animate + * + * @var string + */ + private $attribute; + + /** + * The 'from' value + * + * @var mixed + */ + private $from; + + /** + * The to value + * + * @var mixed + */ + private $to; + + /** + * The begin value (in seconds) + * + * @var float + */ + private $begin = 0; + + /** + * The duration value (in seconds) + * + * @var float + */ + private $duration = 0.5; + + /** + * Create an animation object + * + * @param string $attribute The attribute to animate + * @param string $from The from value for the animation + * @param string $to The to value for the animation + * @param float $duration The duration of the duration + * @param float $begin The begin of the duration + */ + public function __construct($attribute, $from, $to, $duration = 0.5, $begin = 0.0) + { + $this->attribute = $attribute; + $this->from = $from; + $this->to = $to; + $this->duration = $duration; + $this->begin = $begin; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + + $animate = $ctx->getDocument()->createElement('animate'); + $animate->setAttribute('attributeName', $this->attribute); + $animate->setAttribute('attributeType', 'XML'); + $animate->setAttribute('from', $this->from); + $animate->setAttribute('to', $this->to); + $animate->setAttribute('begin', $this->begin . 's'); + $animate->setAttribute('dur', $this->duration . 's'); + $animate->setAttributE('fill', "freeze"); + + return $animate; + } +} diff --git a/library/Icinga/Chart/Primitive/Canvas.php b/library/Icinga/Chart/Primitive/Canvas.php new file mode 100644 index 0000000..32f06bf --- /dev/null +++ b/library/Icinga/Chart/Primitive/Canvas.php @@ -0,0 +1,140 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\LayoutBox; +use Icinga\Chart\Render\RenderContext; + +/** + * Canvas SVG component that encapsulates grouping and padding and allows rendering + * multiple elements in a group + * + */ +class Canvas implements Drawable +{ + /** + * The name of the canvas, will be used as the id + * + * @var string + */ + private $name; + + /** + * An array of child elements of this Canvas + * + * @var array + */ + private $children = array(); + + /** + * When true, this canvas is encapsulated in a clipPath tag and not drawn + * + * @var bool + */ + private $isClipPath = false; + + /** + * The LayoutBox of this Canvas + * + * @var LayoutBox + */ + private $rect; + + /** + * The aria role used to describe this canvas' purpose in the accessibility tree + * + * @var string + */ + private $ariaRole; + + /** + * Create this canvas + * + * @param String $name The name of this canvas + * @param LayoutBox $rect The layout and size of this canvas + */ + public function __construct($name, LayoutBox $rect) + { + $this->rect = $rect; + $this->name = $name; + } + + /** + * Convert this canvas to a clipPath element + */ + public function toClipPath() + { + $this->isClipPath = true; + } + + /** + * Return the layout of this canvas + * + * @return LayoutBox + */ + public function getLayout() + { + return $this->rect; + } + + /** + * Add an element to this canvas + * + * @param Drawable $child + */ + public function addElement(Drawable $child) + { + $this->children[] = $child; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + if ($this->isClipPath) { + $outer = $doc->createElement('defs'); + $innerContainer = $element = $doc->createElement('clipPath'); + $outer->appendChild($element); + } else { + $outer = $element = $doc->createElement('g'); + $innerContainer = $doc->createElement('g'); + $innerContainer->setAttribute('x', 0); + $innerContainer->setAttribute('y', 0); + $innerContainer->setAttribute('id', $this->name . '_inner'); + $innerContainer->setAttribute('transform', $this->rect->getInnerTransform($ctx)); + $element->appendChild($innerContainer); + } + + $element->setAttribute('id', $this->name); + foreach ($this->children as $child) { + $innerContainer->appendChild($child->toSvg($ctx)); + } + + if (isset($this->ariaRole)) { + $outer->setAttribute('role', $this->ariaRole); + } + return $outer; + } + + /** + * Set the aria role used to determine the meaning of this canvas in the accessibility tree + * + * The role 'presentation' will indicate that the purpose of this canvas is entirely decorative, while the role + * 'img' will indicate that the canvas contains an image, with a possible title or a description. For other + * possible roles, see http://www.w3.org/TR/wai-aria/roles + * + * @param $role string The aria role to set + */ + public function setAriaRole($role) + { + $this->ariaRole = $role; + } +} diff --git a/library/Icinga/Chart/Primitive/Circle.php b/library/Icinga/Chart/Primitive/Circle.php new file mode 100644 index 0000000..2cb2b72 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Circle.php @@ -0,0 +1,68 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Drawable for svg circles + */ +class Circle extends Styleable implements Drawable +{ + /** + * The circles x position + * + * @var int + */ + private $x; + + /** + * The circles y position + * + * @var int + */ + private $y; + + /** + * The circles radius + * + * @var int + */ + private $radius; + + /** + * Construct the circle + * + * @param int $x The x position of the circle + * @param int $y The y position of the circle + * @param int $radius The radius of the circle + */ + public function __construct($x, $y, $radius) + { + $this->x = $x; + $this->y = $y; + $this->radius = $radius; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $coords = $ctx->toAbsolute($this->x, $this->y); + $circle = $ctx->getDocument()->createElement('circle'); + $circle->setAttribute('cx', Format::formatSVGNumber($coords[0])); + $circle->setAttribute('cy', Format::formatSVGNumber($coords[1])); + $circle->setAttribute('r', $this->radius); + $circle->setAttribute('style', $this->getStyle()); + $this->applyAttributes($circle); + return $circle; + } +} diff --git a/library/Icinga/Chart/Primitive/Drawable.php b/library/Icinga/Chart/Primitive/Drawable.php new file mode 100644 index 0000000..5b4355c --- /dev/null +++ b/library/Icinga/Chart/Primitive/Drawable.php @@ -0,0 +1,22 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; + +/** + * Drawable element for creating svg out of components + */ +interface Drawable +{ + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx); +} diff --git a/library/Icinga/Chart/Primitive/Line.php b/library/Icinga/Chart/Primitive/Line.php new file mode 100644 index 0000000..5916bb5 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Line.php @@ -0,0 +1,87 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Drawable for the svg line element + */ +class Line extends Styleable implements Drawable +{ + + /** + * The default stroke width + * + * @var int + */ + public $strokeWidth = 1; + + /** + * The line's start x coordinate + * + * @var int + */ + private $xStart = 0; + + /** + * The line's end x coordinate + * + * @var int + */ + private $xEnd = 0; + + /** + * The line's start y coordinate + * + * @var int + */ + private $yStart = 0; + + /** + * The line's end y coordinate + * + * @var int + */ + private $yEnd = 0; + + /** + * Create a line object starting at the first coordinate and ending at the second one + * + * @param int $x1 The line's start x coordinate + * @param int $y1 The line's start y coordinate + * @param int $x2 The line's end x coordinate + * @param int $y2 The line's end y coordinate + */ + public function __construct($x1, $y1, $x2, $y2) + { + $this->xStart = $x1; + $this->xEnd = $x2; + $this->yStart = $y1; + $this->yEnd = $y2; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + list($x1, $y1) = $ctx->toAbsolute($this->xStart, $this->yStart); + list($x2, $y2) = $ctx->toAbsolute($this->xEnd, $this->yEnd); + $line = $doc->createElement('line'); + $line->setAttribute('x1', Format::formatSVGNumber($x1)); + $line->setAttribute('x2', Format::formatSVGNumber($x2)); + $line->setAttribute('y1', Format::formatSVGNumber($y1)); + $line->setAttribute('y2', Format::formatSVGNumber($y2)); + $line->setAttribute('style', $this->getStyle()); + $this->applyAttributes($line); + return $line; + } +} diff --git a/library/Icinga/Chart/Primitive/Path.php b/library/Icinga/Chart/Primitive/Path.php new file mode 100644 index 0000000..2fa793a --- /dev/null +++ b/library/Icinga/Chart/Primitive/Path.php @@ -0,0 +1,174 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Drawable for creating a svg path element + */ +class Path extends Styleable implements Drawable +{ + /** + * Syntax template for moving + * + * @see http://www.w3.org/TR/SVG/paths.html#PathDataMovetoCommands + */ + const TPL_MOVE = 'M %s %s '; + + /** + * Syntax template for bezier curve + * + * @see http://www.w3.org/TR/SVG/paths.html#PathDataCubicBezierCommands + */ + const TPL_BEZIER = 'S %s %s '; + + /** + * Syntax template for straight lines + * + * @see http://www.w3.org/TR/SVG/paths.html#PathDataLinetoCommands + */ + const TPL_STRAIGHT = 'L %s %s '; + + /** + * The default stroke width + * + * @var int + */ + public $strokeWidth = 1; + + /** + * True to treat coordinates as absolute values + * + * @var bool + */ + protected $isAbsolute = false; + + /** + * The points to draw, in the order they are drawn + * + * @var array + */ + protected $points = array(); + + /** + * True to draw the path discrete, i.e. make hard steps between points + * + * @var bool + */ + protected $discrete = false; + + /** + * Create the path using the given points + * + * @param array $points Either a single [x, y] point or an array of x, y points + */ + public function __construct(array $points) + { + $this->append($points); + } + + /** + * Append a single point or an array of points to this path + * + * @param array $points Either a single [x, y] point or an array of x, y points + * + * @return $this Fluid interface + */ + public function append(array $points) + { + if (count($points) === 0) { + return $this; + } + if (!is_array($points[0])) { + $points = array($points); + } + $this->points = array_merge($this->points, $points); + return $this; + } + + /** + * Prepend a single point or an array of points to this path + * + * @param array $points Either a single [x, y] point or an array of x, y points + * + * @return $this Fluid interface + */ + public function prepend(array $points) + { + if (count($points) === 0) { + return $this; + } + if (!is_array($points[0])) { + $points = array($points); + } + $this->points = array_merge($points, $this->points); + return $this; + } + + /** + * Set this path to be discrete + * + * @param boolean $bool True to draw discrete or false to draw straight lines between points + * + * @return $this Fluid interface + */ + public function setDiscrete($bool) + { + $this->discrete = $bool; + return $this; + } + + /** + * Mark this path as containing absolute coordinates + * + * @return $this Fluid interface + */ + public function toAbsolute() + { + $this->isAbsolute = true; + return $this; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + $group = $doc->createElement('g'); + + $pathDescription = ''; + $tpl = self::TPL_MOVE; + $lastPoint = null; + foreach ($this->points as $point) { + if (!$this->isAbsolute) { + $point = $ctx->toAbsolute($point[0], $point[1]); + } + $point[0] = Format::formatSVGNumber($point[0]); + $point[1] = Format::formatSVGNumber($point[1]); + if ($lastPoint && $this->discrete) { + $pathDescription .= sprintf($tpl, $point[0], $lastPoint[1]); + } + $pathDescription .= vsprintf($tpl, $point); + $lastPoint = $point; + $tpl = self::TPL_STRAIGHT; + } + + $path = $doc->createElement('path'); + if ($this->id) { + $path->setAttribute('id', $this->id); + } + $path->setAttribute('d', $pathDescription); + $path->setAttribute('style', $this->getStyle()); + $this->applyAttributes($path); + $group->appendChild($path); + return $group; + } +} diff --git a/library/Icinga/Chart/Primitive/PieSlice.php b/library/Icinga/Chart/Primitive/PieSlice.php new file mode 100644 index 0000000..f5a0ce9 --- /dev/null +++ b/library/Icinga/Chart/Primitive/PieSlice.php @@ -0,0 +1,293 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Component for drawing a pie slice + */ +class PieSlice extends Animatable implements Drawable +{ + /** + * The radius of this pieslice relative to the canvas + * + * @var int + */ + private $radius = 50; + + /** + * The start radian of the pie slice + * + * @var float + */ + private $startRadian = 0; + + /** + * The end radian of the pie slice + * + * @var float + */ + private $endRadian = 0; + + /** + * The x position of the pie slice's center + * + * @var int + */ + private $x; + + /** + * The y position of the pie slice's center + * + * @var int + */ + private $y; + + /** + * The caption of the pie slice, empty string means no caption + * + * @var string + */ + private $caption = ""; + + /** + * The offset of the caption, shifting the indicator from the center of the pie slice + * + * This is required for nested pie slices. + * + * @var int + */ + private $captionOffset = 0; + + /** + * The minimum radius the label must respect + * + * @var int + */ + private $outerCaptionBound = 0; + + /** + * An optional group element to add labels to when rendering + * + * @var DOMElement + */ + private $labelGroup; + + /** + * Create a pie slice + * + * @param int $radius The radius of the slice + * @param int $percent The percentage the slice represents + * @param int $percentStart The percentage where this slice starts + */ + public function __construct($radius, $percent, $percentStart = 0) + { + $this->x = $this->y = $this->radius = $radius; + + $this->startRadian = M_PI * $percentStart/50; + $this->endRadian = M_PI * ($percent + $percentStart)/50; + } + + /** + * Create the path for the pie slice + * + * @param int $x The x position of the pie slice + * @param int $y The y position of the pie slice + * @param int $r The absolute radius of the pie slice + * + * @return string A SVG path string + */ + private function getPieSlicePath($x, $y, $r) + { + // The coordinate system is mirrored on the Y axis, so we have to flip cos and sin + $xStart = $x + ($r * sin($this->startRadian)); + $yStart = $y - ($r * cos($this->startRadian)); + + if ($this->endRadian - $this->startRadian == 2*M_PI) { + // To draw a full circle, adjust arc endpoint by a small (unvisible) value + $this->endRadian -= 0.001; + $pathString = 'M ' . Format::formatSVGNumber($xStart) . ' ' . Format::formatSVGNumber($yStart); + } else { + // Start at the center of the pieslice + $pathString = 'M ' . $x . ' ' . $y; + // Draw a straight line to the upper part of the arc + $pathString .= ' L ' . Format::formatSVGNumber($xStart) . ' ' . Format::formatSVGNumber($yStart); + } + + // Instead of directly connecting the upper part of the arc (leaving a triangle), draw a bow with the radius + $pathString .= ' A ' . Format::formatSVGNumber($r) . ' ' . Format::formatSVGNumber($r); + // These are the flags for the bow, see the SVG path documentation for details + // http://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands + $pathString .= ' 0 ' . (($this->endRadian - $this->startRadian > M_PI) ? '1' : '0 ') . ' 1'; + + // xEnd and yEnd are the lower point of the arc + $xEnd = $x + ($r * sin($this->endRadian)); + $yEnd = $y - ($r * cos($this->endRadian)); + $pathString .= ' ' . Format::formatSVGNumber($xEnd) . ' ' . Format::formatSVGNumber($yEnd); + + return $pathString; + } + + /** + * Draw the label handler and the text for this pie slice + * + * @param RenderContext $ctx The rendering context to use for coordinate translation + * @param int $r The radius of the pie in absolute coordinates + * + * @return DOMElement The group DOMElement containing the handle and label + */ + private function drawDescriptionLabel(RenderContext $ctx, $r) + { + $group = $ctx->getDocument()->createElement('g'); + $rOuter = ($ctx->xToAbsolute($this->outerCaptionBound) + $ctx->yToAbsolute($this->outerCaptionBound)) / 2; + $addOffset = $rOuter - $r ; + if ($addOffset < 0) { + $addOffset = 0; + } + list($x, $y) = $ctx->toAbsolute($this->x, $this->y); + $midRadius = $this->startRadian + ($this->endRadian - $this->startRadian) / 2; + list($offsetX, $offsetY) = $ctx->toAbsolute($this->captionOffset, $this->captionOffset); + + $midX = $x + intval(($offsetX + $r)/2 * sin($midRadius)); + $midY = $y - intval(($offsetY + $r)/2 * cos($midRadius)); + + // Draw the handle + $path = new Path(array($midX, $midY)); + + $midX += ($addOffset + $r/3) * ($midRadius > M_PI ? -1 : 1); + $path->append(array($midX, $midY))->toAbsolute(); + + $midX += intval($r/2 * sin(M_PI/9)) * ($midRadius > M_PI ? -1 : 1); + $midY -= intval($r/2 * cos(M_PI/3)) * ($midRadius < M_PI*1.4 && $midRadius > M_PI/3 ? -1 : 1); + + if ($ctx->ytoRelative($midY) > 100) { + $midY = $ctx->yToAbsolute(100); + } elseif ($ctx->ytoRelative($midY) < 0) { + $midY = $ctx->yToAbsolute($ctx->ytoRelative(100+$midY)); + } + + $path->append(array($midX , $midY)); + $rel = $ctx->toRelative($midX, $midY); + + // Draw the text box + $text = new Text($rel[0]+1.5, $rel[1], $this->caption); + $text->setFontSize('5em'); + $text->setAlignment(($midRadius > M_PI ? Text::ALIGN_END : Text::ALIGN_START)); + + $group->appendChild($path->toSvg($ctx)); + $group->appendChild($text->toSvg($ctx)); + + return $group; + } + + /** + * Set the x position of the pie slice + * + * @param int $x The new x position + * + * @return $this Fluid interface + */ + public function setX($x) + { + $this->x = $x; + return $this; + } + + /** + * Set the y position of the pie slice + * + * @param int $y The new y position + * + * @return $this Fluid interface + */ + public function setY($y) + { + $this->y = $y; + return $this; + } + + /** + * Set a root element to be used for drawing labels + * + * @param DOMElement $group The label group + * + * @return $this Fluid interface + */ + public function setLabelGroup(DOMElement $group) + { + $this->labelGroup = $group; + return $this; + } + + /** + * Set the caption for this label + * + * @param string $caption The caption for this element + * + * @return $this Fluid interface + */ + public function setCaption($caption) + { + $this->caption = $caption; + return $this; + } + + /** + * Set the internal offset of the caption handle + * + * @param int $offset The offset for the caption handle + * + * @return $this Fluid interface + */ + public function setCaptionOffset($offset) + { + $this->captionOffset = $offset; + return $this; + } + + /** + * Set the minimum radius to be used for drawing labels + * + * @param int $bound The offset for the caption text + * + * @return $this Fluid interface + */ + public function setOuterCaptionBound($bound) + { + $this->outerCaptionBound = $bound; + return $this; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + $group = $doc->createElement('g'); + $r = ($ctx->xToAbsolute($this->radius) + $ctx->yToAbsolute($this->radius)) / 2; + list($x, $y) = $ctx->toAbsolute($this->x, $this->y); + + $slicePath = $doc->createElement('path'); + + $slicePath->setAttribute('d', $this->getPieSlicePath($x, $y, $r)); + $slicePath->setAttribute('style', $this->getStyle()); + $slicePath->setAttribute('data-icinga-graph-type', 'pieslice'); + + $this->applyAttributes($slicePath); + $group->appendChild($slicePath); + if ($this->caption != "") { + $lblGroup = ($this->labelGroup ? $this->labelGroup : $group); + $lblGroup->appendChild($this->drawDescriptionLabel($ctx, $r)); + } + return $group; + } +} diff --git a/library/Icinga/Chart/Primitive/RawElement.php b/library/Icinga/Chart/Primitive/RawElement.php new file mode 100644 index 0000000..721b6e0 --- /dev/null +++ b/library/Icinga/Chart/Primitive/RawElement.php @@ -0,0 +1,43 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; + +/** + * Wrapper for raw elements to be added as Drawable's + */ +class RawElement implements Drawable +{ + + /** + * The DOMElement wrapped by this Drawable + * + * @var DOMElement + */ + private $domEl; + + /** + * Create this RawElement + * + * @param DOMElement $el The element to wrap here + */ + public function __construct(DOMElement $el) + { + $this->domEl = $el; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + return $this->domEl; + } +} diff --git a/library/Icinga/Chart/Primitive/Rect.php b/library/Icinga/Chart/Primitive/Rect.php new file mode 100644 index 0000000..0145e07 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Rect.php @@ -0,0 +1,105 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DomElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Drawable representing the SVG rect element + */ +class Rect extends Animatable implements Drawable +{ + /** + * The x position + * + * @var int + */ + private $x; + + /** + * The y position + * + * @var int + */ + private $y; + + /** + * The width of this rect + * + * @var int + */ + private $width; + + /** + * The height of this rect + * + * @var int + */ + private $height; + + /** + * Whether to keep the ratio + * + * @var bool + */ + private $keepRatio = false; + + /** + * Create this rect + * + * @param int $x The x position of the rect + * @param int $y The y position of the rectangle + * @param int $width The width of the rectangle + * @param int $height The height of the rectangle + */ + public function __construct($x, $y, $width, $height) + { + $this->x = $x; + $this->y = $y; + $this->width = $width; + $this->height = $height; + } + + /** + * Call to let the rectangle keep the ratio + */ + public function keepRatio() + { + $this->keepRatio = true; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + $rect = $doc->createElement('rect'); + + list($x, $y) = $ctx->toAbsolute($this->x, $this->y); + if ($this->keepRatio) { + $ctx->keepRatio(); + } + list($width, $height) = $ctx->toAbsolute($this->width, $this->height); + if ($this->keepRatio) { + $ctx->ignoreRatio(); + } + $rect->setAttribute('x', Format::formatSVGNumber($x)); + $rect->setAttribute('y', Format::formatSVGNumber($y)); + $rect->setAttribute('width', Format::formatSVGNumber($width)); + $rect->setAttribute('height', Format::formatSVGNumber($height)); + $rect->setAttribute('style', $this->getStyle()); + + $this->applyAttributes($rect); + $this->appendAnimation($rect, $ctx); + + return $rect; + } +} diff --git a/library/Icinga/Chart/Primitive/Styleable.php b/library/Icinga/Chart/Primitive/Styleable.php new file mode 100644 index 0000000..678b940 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Styleable.php @@ -0,0 +1,154 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Primitive; + +use DOMElement; + +/** + * Base class for stylable drawables + */ +class Styleable +{ + + /** + * The stroke width to use + * + * @var int + */ + public $strokeWidth = 0; + + /** + * The stroke color to use + * + * @var string + */ + public $strokeColor = '#000'; + + /** + * The fill color to use + * + * @var string + */ + public $fill = 'none'; + + /** + * Additional styles to be appended to the style attribute + * + * @var string + */ + public $additionalStyle = ''; + + /** + * The id of this element + * + * @var string + */ + public $id = null; + + /** + * Additional attributes to be set + * + * @var array + */ + public $attributes = array(); + + /** + * Set the stroke width for this drawable + * + * @param string $width The stroke with with unit + * + * @return $this Fluid interface + */ + public function setStrokeWidth($width) + { + $this->strokeWidth = $width; + return $this; + } + + /** + * Set the color for the stroke or none for no stroke + * + * @param string $color The color to set for the stroke + * + * @return $this Fluid interface + */ + public function setStrokeColor($color) + { + $this->strokeColor = $color ? $color : 'none'; + return $this; + } + + /** + * Set additional styles for this drawable + * + * @param string $styles The styles to set additionally + * + * @return $this Fluid interface + */ + public function setAdditionalStyle($styles) + { + $this->additionalStyle = $styles; + return $this; + } + + /** + * Set the fill for this styleable + * + * @param string $color The color to use for filling or null to use no fill + * + * @return $this Fluid interface + */ + public function setFill($color = null) + { + $this->fill = $color ? $color : 'none'; + return $this; + } + + /** + * Set the id for this element + * + * @param string $id The id to set for this element + * + * @return $this Fluid interface + */ + public function setId($id) + { + $this->id = $id; + + return $this; + } + + /** + * Return the content of the style attribute as a string + * + * @return string A string containing styles + */ + public function getStyle() + { + $base = sprintf("fill: %s; stroke: %s;stroke-width: %s;", $this->fill, $this->strokeColor, $this->strokeWidth); + $base .= ';' . $this->additionalStyle . ';'; + return $base; + } + + /** + * Add an additional attribute to this element + */ + public function setAttribute($key, $value) + { + $this->attributes[$key] = $value; + } + + /** + * Apply attribute to a DOMElement + * + * @param DOMElement $el Element to apply attributes + */ + protected function applyAttributes(DOMElement $el) + { + foreach ($this->attributes as $name => $value) { + $el->setAttribute($name, $value); + } + } +} diff --git a/library/Icinga/Chart/Primitive/Text.php b/library/Icinga/Chart/Primitive/Text.php new file mode 100644 index 0000000..e647f40 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Text.php @@ -0,0 +1,168 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use DOMText; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Wrapper for the SVG text element + */ +class Text extends Styleable implements Drawable +{ + /** + * Align the text to end at the x and y position + */ + const ALIGN_END = 'end'; + + /** + * Align the text to start at the x and y position + */ + const ALIGN_START = 'start'; + + /** + * Align the text to be centered at the x and y position + */ + const ALIGN_MIDDLE = 'middle'; + + /** + * The x position of the Text + * + * @var int + */ + private $x; + + /** + * The y position of the Text + * + * @var int + */ + private $y; + + /** + * The text content + * + * @var string + */ + private $text; + + /** + * The size of the font + * + * @var string + */ + private $fontSize = '1.5em'; + + /** + * The weight of the font + * + * @var string + */ + private $fontWeight = 'normal'; + + /** + * The default fill color + * + * @var string + */ + public $fill = '#000'; + + /** + * The alignment of the text + * + * @var string + */ + private $alignment = self::ALIGN_START; + + /** + * Set the font-stretch property of the text + */ + private $fontStretch = 'semi-condensed'; + + /** + * Construct a new text drawable + * + * @param int $x The x position of the text + * @param int $y The y position of the text + * @param string $text The text this component should contain + * @param string $fontSize The font size of the text + */ + public function __construct($x, $y, $text, $fontSize = '1.5em') + { + $this->x = $x; + $this->y = $y; + $this->text = $text; + $this->fontSize = $fontSize; + } + + /** + * Set the font size of the svg text element + * + * @param string $size The font size including a unit + * + * @return $this Fluid interface + */ + public function setFontSize($size) + { + $this->fontSize = $size; + return $this; + } + + /** + * Set the text alignment with one of the ALIGN_* constants + * + * @param String $align Value how to align + * + * @return $this Fluid interface + */ + public function setAlignment($align) + { + $this->alignment = $align; + return $this; + } + + /** + * Set the weight of the current font + * + * @param string $weight The weight of the string + * + * @return $this Fluid interface + */ + public function setFontWeight($weight) + { + $this->fontWeight = $weight; + return $this; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + list($x, $y) = $ctx->toAbsolute($this->x, $this->y); + $text = $ctx->getDocument()->createElement('text'); + $text->setAttribute('x', Format::formatSVGNumber($x - 15)); + $text->setAttribute( + 'style', + $this->getStyle() + . ';font-size:' . $this->fontSize + . '; font-family: Ubuntu, Calibri, Trebuchet MS, Helvetica, Verdana, sans-serif' + . ';font-weight: ' . $this->fontWeight + . ';font-stretch: ' . $this->fontStretch + . '; font-style: normal;' + . 'text-anchor: ' . $this->alignment + ); + + $text->setAttribute('y', Format::formatSVGNumber($y)); + $text->appendChild(new DOMText($this->text)); + return $text; + } +} diff --git a/library/Icinga/Chart/Render/LayoutBox.php b/library/Icinga/Chart/Render/LayoutBox.php new file mode 100644 index 0000000..fa49461 --- /dev/null +++ b/library/Icinga/Chart/Render/LayoutBox.php @@ -0,0 +1,200 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Render; + +use Icinga\Chart\Format; + +/** + * Layout class encapsulating size, padding and margin information + */ +class LayoutBox +{ + /** + * Padding index for top padding + */ + const PADDING_TOP = 0; + + /** + * Padding index for right padding + */ + const PADDING_RIGHT = 1; + + /** + * Padding index for bottom padding + */ + const PADDING_BOTTOM = 2; + + /** + * Padding index for left padding + */ + const PADDING_LEFT = 3; + + /** + * The height of this layout element + * + * @var int + */ + private $height; + + /** + * The width of this layout element + * + * @var int + */ + private $width; + + /** + * The x position of this layout + * + * @var int + */ + private $x; + + /** + * The y position of this layout + * + * @var int + */ + private $y; + + /** + * The padding of this layout + * + * @var array + */ + private $padding = array(0, 0, 0, 0); + + /** + * Create this layout box + * + * Note that x, y, width and height are relative: x with 0 means leftmost, x with 100 means rightmost + * + * @param int $x The relative x coordinate + * @param int $y The relative y coordinate + * @param int $width The optional, relative width + * @param int $height The optional, relative height + */ + public function __construct($x, $y, $width = null, $height = null) + { + $this->height = $height ? $height : 100; + $this->width = $width ? $width : 100; + $this->x = $x; + $this->y = $y; + } + + /** + * Set a padding to all four sides uniformly + * + * @param int $padding The padding to set for all four sides + */ + public function setUniformPadding($padding) + { + $this->padding = array($padding, $padding, $padding, $padding); + } + + /** + * Set the padding for this LayoutBox + * + * @param int $top The top side padding + * @param int $right The right side padding + * @param int $bottom The bottom side padding + * @param int $left The left side padding + */ + public function setPadding($top, $right, $bottom, $left) + { + $this->padding = array($top, $right, $bottom, $left); + } + + /** + * Return a string containing the SVG transform attribute values for the padding + * + * @param RenderContext $ctx The context to determine the translation coordinates + * + * @return string The transformation string + */ + public function getInnerTransform(RenderContext $ctx) + { + list($translateX, $translateY) = $ctx->toAbsolute( + $this->padding[self::PADDING_LEFT] + $this->getX(), + $this->padding[self::PADDING_TOP] + $this->getY() + ); + list($scaleX, $scaleY) = $ctx->paddingToScaleFactor($this->padding); + + $scaleX *= $this->getWidth()/100; + $scaleY *= $this->getHeight()/100; + return sprintf( + 'translate(%s, %s) scale(%s, %s)', + Format::formatSVGNumber($translateX), + Format::formatSVGNumber($translateY), + Format::formatSVGNumber($scaleX), + Format::formatSVGNumber($scaleY) + ); + } + + /** + * String representation for this Layout, for debug purposes + * + * @return string A string containing the bounds of this LayoutBox + */ + public function __toString() + { + return sprintf( + 'Rectangle: x: %s y: %s, height: %s, width: %s', + $this->x, + $this->y, + $this->height, + $this->width + ); + } + + /** + * Return a four element array with the padding + * + * @return array The padding of this LayoutBox + */ + public function getPadding() + { + return $this->padding; + } + + /** + * Return the height of this LayoutBox + * + * @return int The height of this box + */ + public function getHeight() + { + return $this->height; + } + + /** + * Return the width of this LayoutBox + * + * @return int The width of this box + */ + public function getWidth() + { + return $this->width; + } + + /** + * Return the x position of this LayoutBox + * + * @return int The x position of this box + */ + public function getX() + { + return $this->x; + } + + /** + * Return the y position of this LayoutBox + * + * @return int The y position of this box + */ + public function getY() + { + return $this->y; + } +} diff --git a/library/Icinga/Chart/Render/RenderContext.php b/library/Icinga/Chart/Render/RenderContext.php new file mode 100644 index 0000000..457fbf3 --- /dev/null +++ b/library/Icinga/Chart/Render/RenderContext.php @@ -0,0 +1,225 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Render; + +use DOMDocument; + +/** + * Context for rendering, handles ratio based coordinate calculations. + * + * The most important functions when rendering are the toAbsolute and roRelative + * values, taking world coordinates and translating them into local coordinates. + */ +class RenderContext +{ + + /** + * The base size of the viewport, i.e. how many units are available on a 1:1 ratio + * + * @var array + */ + private $viewBoxSize = array(1000, 1000); + + + /** + * The DOMDocument for modifying the elements + * + * @var DOMDocument + */ + private $document; + + /** + * If true no ratio correction will be made + * + * @var bool + */ + private $respectRatio = false; + + /** + * The ratio on the x side. A x ration of 2 means that the width of the SVG is divided in 2000 + * units (see $viewBox) + * + * @var int + */ + private $xratio = 1; + + /** + * The ratio on the y side. A y ration of 2 means that the height of the SVG is divided in 2000 + * units (see $viewBox) + * + * @var int + */ + private $yratio = 1; + + /** + * Creates a new context for the given DOM Document + * + * @param DOMDocument $document The DOM document represented by this context + * @param int $width The width (may be approximate) of the document + * (only required for ratio calculation) + * @param int $height The height (may be approximate) of the document + * (only required for ratio calculation) + */ + public function __construct(DOMDocument $document, $width, $height) + { + $this->document = $document; + if ($width > $height) { + $this->xratio = $width / $height; + } elseif ($height > $width) { + $this->yratio = $height / $width; + } + } + + /** + * Return the document represented by this Rendering context + * + * @return DOMDocument The DOMDocument for creating files + */ + public function getDocument() + { + return $this->document; + } + + /** + * Let successive toAbsolute operations ignore ratio correction + * + * This can be called to avoid distortion on certain elements like rectangles. + */ + public function keepRatio() + { + $this->respectRatio = true; + } + + /** + * Let successive toAbsolute operations perform ratio correction + * + * This will cause distortion on certain elements like rectangles. + */ + public function ignoreRatio() + { + $this->respectRatio = false; + } + + /** + * Return how many unit s are available in the Y axis + * + * @return int The number of units available on the y axis + */ + public function getNrOfUnitsY() + { + return intval($this->viewBoxSize[1] * $this->yratio); + } + + /** + * Return how many unit s are available in the X axis + * + * @return int The number of units available on the x axis + */ + public function getNrOfUnitsX() + { + return intval($this->viewBoxSize[0] * $this->xratio); + } + + /** + * Transforms the x,y coordinate from relative coordinates to absolute world coordinates + * + * (50, 50) would be a point in the middle of the document and map to 500, 1000 on a + * 1000 x 1000 viewbox with a 1:2 ratio. + * + * @param int $x The relative x coordinate + * @param int $y The relative y coordinate + * + * @return array An x,y tuple containing absolute coordinates + * @see RenderContext::toRelative + */ + public function toAbsolute($x, $y) + { + return array($this->xToAbsolute($x), $this->yToAbsolute($y)); + } + + /** + * Transforms the x,y coordinate from absolute coordinates to relative world coordinates + * + * This is the inverse function of toAbsolute + * + * @param int $x The absolute x coordinate + * @param int $y The absolute y coordinate + * + * @return array An x,y tupel containing absolute coordinates + * @see RenderContext::toAbsolute + */ + public function toRelative($x, $y) + { + return array($this->xToRelative($x), $this->yToRelative($y)); + } + + /** + * Calculates the scale transformation required to apply the padding on an Canvas + * + * @param array $padding A 4 element array containing top, right, bottom and left padding + * + * @return array An array containing the x and y scale + */ + public function paddingToScaleFactor(array $padding) + { + list($horizontalPadding, $verticalPadding) = $this->toAbsolute( + $padding[LayoutBox::PADDING_RIGHT] + $padding[LayoutBox::PADDING_LEFT], + $padding[LayoutBox::PADDING_TOP] + $padding[LayoutBox::PADDING_BOTTOM] + ); + + return array( + ($this->getNrOfUnitsX() - $horizontalPadding) / $this->getNrOfUnitsX(), + ($this->getNrOfUnitsY() - $verticalPadding) / $this->getNrOfUnitsY() + ); + } + + /** + * Transform a relative x coordinate to an absolute one + * + * @param int $x A relative x coordinate + * + * @return int An absolute x coordinate + **/ + public function xToAbsolute($x) + { + return $this->getNrOfUnitsX() / 100 * $x / ($this->respectRatio ? $this->xratio : 1); + } + + /** + * Transform a relative y coordinate to an absolute one + * + * @param int $y A relative y coordinate + * + * @return int An absolute y coordinate + */ + public function yToAbsolute($y) + { + return $this->getNrOfUnitsY() / 100 * $y / ($this->respectRatio ? $this->yratio : 1); + } + + /** + * Transform a absolute x coordinate to an relative one + * + * @param int $x An absolute x coordinate + * + * @return int A relative x coordinate + */ + public function xToRelative($x) + { + return $x / $this->getNrOfUnitsX() * 100 * ($this->respectRatio ? $this->xratio : 1); + } + + /** + * Transform a absolute y coordinate to an relative one + * + * @param int $y An absolute x coordinate + * + * @return int A relative x coordinate + */ + public function yToRelative($y) + { + return $y / $this->getNrOfUnitsY() * 100 * ($this->respectRatio ? $this->yratio : 1); + } +} diff --git a/library/Icinga/Chart/Render/Rotator.php b/library/Icinga/Chart/Render/Rotator.php new file mode 100644 index 0000000..3e7071c --- /dev/null +++ b/library/Icinga/Chart/Render/Rotator.php @@ -0,0 +1,80 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Render; + +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Primitive\Drawable; +use DOMElement; + +/** + * Class Rotator + * @package Icinga\Chart\Render + */ +class Rotator implements Drawable +{ + /** + * The drawable element to rotate + * + * @var Drawable + */ + private $element; + + /** + * @var int + */ + private $degrees; + + /** + * Wrap an element into a new instance of Rotator + * + * @param Drawable $element The element to rotate + * @param int $degrees The amount of degrees + */ + public function __construct(Drawable $element, $degrees) + { + $this->element = $element; + $this->degrees = $degrees; + } + + /** + * Rotate the given element. + * + * @param RenderContext $ctx The rendering context + * @param DOMElement $el The element to rotate + * @param $degrees The amount of degrees + * + * @return DOMElement The rotated DOMElement + */ + private function rotate(RenderContext $ctx, DOMElement $el, $degrees) + { + // Create a box containing the rotated element relative to the original element position + $container = $ctx->getDocument()->createElement('g'); + $x = $el->getAttribute('x'); + $y = $el->getAttribute('y'); + $container->setAttribute('transform', 'translate(' . $x . ',' . $y . ')'); + $el->removeAttribute('x'); + $el->removeAttribute('y'); + + // Put the element into a rotated group + //$rotate = $ctx->getDocument()->createElement('g'); + $el->setAttribute('transform', 'rotate(' . $degrees . ')'); + //$rotate->appendChild($el); + + $container->appendChild($el); + return $container; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $el = $this->element->toSvg($ctx); + return $this->rotate($ctx, $el, $this->degrees); + } +} diff --git a/library/Icinga/Chart/SVGRenderer.php b/library/Icinga/Chart/SVGRenderer.php new file mode 100644 index 0000000..d3891f2 --- /dev/null +++ b/library/Icinga/Chart/SVGRenderer.php @@ -0,0 +1,331 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMNode; +use DOMElement; +use DOMDocument; +use DOMImplementation; +use Icinga\Chart\Render\LayoutBox; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Primitive\Canvas; + +/** + * SVG Renderer component. + * + * Creates the basic DOM tree of the SVG to use + */ +class SVGRenderer +{ + const X_ASPECT_RATIO_MIN = 'xMin'; + + const X_ASPECT_RATIO_MID = 'xMid'; + + const X_ASPECT_RATIO_MAX = 'xMax'; + + const Y_ASPECT_RATIO_MIN = 'YMin'; + + const Y_ASPECT_RATIO_MID = 'YMid'; + + const Y_ASPECT_RATIO_MAX = 'YMax'; + + const ASPECT_RATIO_PAD = 'meet'; + + const ASPECT_RATIO_CUTOFF = 'slice'; + + /** + * The XML-document + * + * @var DOMDocument + */ + private $document; + + /** + * The SVG-element + * + * @var DOMNode + */ + private $svg; + + /** + * The description of this SVG, useful for screen readers + * + * @var string + */ + private $ariaDescription; + + /** + * The title of this SVG, useful for screen readers + * + * @var string + */ + private $ariaTitle; + + /** + * The aria role used by this svg element + * + * @var string + */ + private $ariaRole = 'img'; + + /** + * The root layer for all elements + * + * @var Canvas + */ + private $rootCanvas; + + /** + * The width of this renderer + * + * @var int + */ + private $width = 100; + + /** + * The height of this renderer + * + * @var int + */ + private $height = 100; + + /** + * Whether the aspect ratio is preversed + * + * @var bool + */ + private $preserveAspectRatio = false; + + /** + * Horizontal alignment of SVG element + * + * @var string + */ + private $xAspectRatio = self::X_ASPECT_RATIO_MID; + + /** + * Vertical alignment of SVG element + * + * @var string + */ + private $yAspectRatio = self::Y_ASPECT_RATIO_MID; + + /** + * Define whether aspect differences should be handled using padding (default) or cutoff + * + * @var string + */ + private $xFillMode = "meet"; + + + /** + * Create the root document and the SVG root node + */ + private function createRootDocument() + { + $implementation = new DOMImplementation(); + $docType = $implementation->createDocumentType( + 'svg', + '-//W3C//DTD SVG 1.1//EN', + 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd' + ); + + $this->document = $implementation->createDocument(null, '', $docType); + $this->svg = $this->createOuterBox(); + $this->document->appendChild($this->svg); + } + + /** + * Create the outer SVG box containing the root svg element and namespace and return it + * + * @return DOMElement The SVG root node + */ + private function createOuterBox() + { + $ctx = $this->createRenderContext(); + $svg = $this->document->createElement('svg'); + $svg->setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + $svg->setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + $svg->setAttribute('role', $this->ariaRole); + $svg->setAttribute('width', '100%'); + $svg->setAttribute('height', '100%'); + $svg->setAttribute( + 'viewBox', + sprintf( + '0 0 %s %s', + $ctx->getNrOfUnitsX(), + $ctx->getNrOfUnitsY() + ) + ); + if ($this->preserveAspectRatio) { + $svg->setAttribute( + 'preserveAspectRatio', + sprintf( + '%s%s %s', + $this->xAspectRatio, + $this->yAspectRatio, + $this->xFillMode + ) + ); + } + return $svg; + } + + /** + * Add aria title and description + * + * Adds an aria title and desc element to the given SVG node, which are used to describe this SVG by accessibility + * tools such as screen readers. + * + * @param DOMNode $svg The SVG DOMNode to which the aria attributes should be attached + * @param $title The title text + * @param $description The description text + */ + private function addAriaDescription(DOMNode $svg, $titleText, $descriptionText) + { + $doc = $svg->ownerDocument; + + $titleId = $descId = ''; + if (isset($this->ariaTitle)) { + $titleId = 'aria-title-' . $this->stripNonAlphanumeric($titleText); + $title = $doc->createElement('title'); + $title->setAttribute('id', $titleId); + + $title->appendChild($doc->createTextNode($titleText)); + $svg->appendChild($title); + } + + if (isset($this->ariaDescription)) { + $descId = 'aria-desc-' . $this->stripNonAlphanumeric($descriptionText); + $desc = $doc->createElement('desc'); + $desc->setAttribute('id', $descId); + + $desc->appendChild($doc->createTextNode($descriptionText)); + $svg->appendChild($desc); + } + + $svg->setAttribute('aria-labelledby', join(' ', array($titleId, $descId))); + } + + /** + * Initialises the XML-document, SVG-element and this figure's root canvas + * + * @param int $width The width ratio + * @param int $height The height ratio + */ + public function __construct($width, $height) + { + $this->width = $width; + $this->height = $height; + $this->rootCanvas = new Canvas('root', new LayoutBox(0, 0)); + } + + /** + * Render the SVG-document + * + * @return string The resulting XML structure + */ + public function render() + { + $this->createRootDocument(); + $ctx = $this->createRenderContext(); + $this->addAriaDescription($this->svg, $this->ariaTitle, $this->ariaDescription); + $this->svg->appendChild($this->rootCanvas->toSvg($ctx)); + $this->document->formatOutput = true; + $this->document->encoding = 'UTF-8'; + return $this->document->saveXML(); + } + + /** + * Create a render context that will be used for rendering elements + * + * @return RenderContext The created RenderContext instance + */ + public function createRenderContext() + { + return new RenderContext($this->document, $this->width, $this->height); + } + + /** + * Return the root canvas of this rendered + * + * @return Canvas The canvas that will be the uppermost element in this figure + */ + public function getCanvas() + { + return $this->rootCanvas; + } + + /** + * Preserve the aspect ratio of the rendered object + * + * Do not deform the content of the SVG when the aspect ratio of the viewBox + * differs from the aspect ratio of the SVG element, but add padding or cutoff + * instead + * + * @param bool $preserve Whether the aspect ratio should be preserved + */ + public function preserveAspectRatio($preserve = true) + { + $this->preserveAspectRatio = $preserve; + } + + /** + * Change the horizontal alignment of the SVG element + * + * Change the horizontal alignment of the svg, when preserveAspectRatio is used and + * padding is present. Defaults to + */ + public function setXAspectRatioAlignment($alignment) + { + $this->xAspectRatio = $alignment; + } + + /** + * Change the vertical alignment of the SVG element + * + * Change the vertical alignment of the svg, when preserveAspectRatio is used and + * padding is present. + */ + public function setYAspectRatioAlignment($alignment) + { + $this->yAspectRatio = $alignment; + } + + /** + * Set the aria description, that is used as a title for this SVG in screen readers + * + * @param $text + */ + public function setAriaTitle($text) + { + $this->ariaTitle = $text; + } + + /** + * Set the aria description, that is used to describe this SVG in screen readers + * + * @param $text + */ + public function setAriaDescription($text) + { + $this->ariaDescription = $text; + } + + /** + * Set the aria role, that is used to describe the purpose of this SVG in screen readers + * + * @param $text + */ + public function setAriaRole($text) + { + $this->ariaRole = $text; + } + + + private function stripNonAlphanumeric($str) + { + return preg_replace('/[^A-Za-z]+/', '', $str); + } +} diff --git a/library/Icinga/Chart/Unit/AxisUnit.php b/library/Icinga/Chart/Unit/AxisUnit.php new file mode 100644 index 0000000..251787f --- /dev/null +++ b/library/Icinga/Chart/Unit/AxisUnit.php @@ -0,0 +1,56 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Unit; + +use Iterator; + +/** + * Base class for Axis Units + * + * An AxisUnit takes a set of values and places them on a given range + * + * Concrete subclasses must implement the iterator interface, with + * getCurrent returning the axis relative position and getValue the label + * that will be displayed + */ +interface AxisUnit extends Iterator +{ + /** + * Add a dataset to this AxisUnit, required for dynamic min and max vlaues + * + * @param array $dataset The dataset that will be shown in the Axis + * @param int $id The idx in the dataset (0 for x, 1 for y) + */ + public function addValues(array $dataset, $id = 0); + + /** + * Transform the given absolute value in an axis relative value + * + * @param int $value The absolute, dataset dependent value + * + * @return int An axis relative value + */ + public function transform($value); + + /** + * Set the axis minimum value to a fixed value + * + * @param int $min The new minimum value + */ + public function setMin($min); + + /** + * Set the axis maximum value to a fixed value + * + * @param int $max The new maximum value + */ + public function setMax($max); + + /** + * Get the amount of ticks of this axis + * + * @return int + */ + public function getTicks(); +} diff --git a/library/Icinga/Chart/Unit/CalendarUnit.php b/library/Icinga/Chart/Unit/CalendarUnit.php new file mode 100644 index 0000000..7ce6a99 --- /dev/null +++ b/library/Icinga/Chart/Unit/CalendarUnit.php @@ -0,0 +1,167 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Unit; + +use DateTime; + +/** + * Calendar Axis Unit that transforms timestamps into user-readable values + * + */ +class CalendarUnit extends LinearUnit +{ + /** + * Constant for a minute + */ + const MINUTE = 60; + + /** + * Constant for an hour + */ + const HOUR = 3600; + + /** + * Constant for a day + */ + const DAY = 864000; + + /** + * Constant for ~a month + * 30 Days, this is sufficient for our needs + */ + const MONTH = 2592000; // x + + /** + * An array containing all labels that will be displayed + * + * @var array + */ + private $labels = array(); + + /** + * The date format to use + * + * @var string + */ + private $dateFormat = 'd-m'; + + /** + * The time format to use + * + * @var string + */ + private $timeFormat = 'g:i:s'; + + /** + * Create the labels for the given dataset + */ + private function createLabels() + { + $this->labels = array(); + $duration = $this->getMax() - $this->getMin(); + + if ($duration <= self::HOUR) { + $unit = self::MINUTE; + } elseif ($duration <= self::DAY) { + $unit = self::HOUR; + } elseif ($duration <= self::MONTH) { + $unit = self::DAY; + } else { + $unit = self::MONTH; + } + $this->calculateLabels($unit); + } + + /** + * Calculate the labels for this dataset + * + * @param integer $unit The unit to use as the basis for calculation + */ + private function calculateLabels($unit) + { + $fac = new DateTime(); + + $duration = $this->getMax() - $this->getMin(); + + // Calculate number of ticks, but not more than 30 + $tickCount = ($duration/$unit * 10); + if ($tickCount > 30) { + $tickCount = 30; + } + + $step = $duration / $tickCount; + $format = $this->timeFormat; + if ($unit === self::DAY) { + $format = $this->dateFormat; + } elseif ($unit === self::MONTH) { + $format = $this->dateFormat; + } + + for ($i = 0; $i <= $duration; $i += $step) { + $this->labels[] = $fac->setTimestamp($this->getMin() + $i)->format($format); + } + } + + /** + * Add a dataset to this CalendarUnit and update labels + * + * @param array $dataset The dataset to update + * @param int $idx The index to use for determining the data + * + * @return $this Fluid interface + */ + public function addValues(array $dataset, $idx = 0) + { + parent::addValues($dataset, $idx); + $this->createLabels(); + return $this; + } + + /** + * Return the current axis relative position + * + * @return int The position of the next tick (between 0 and 100) + */ + public function current() + { + return 100 * (key($this->labels) / count($this->labels)); + } + + /** + * Move to next tick + */ + public function next() + { + next($this->labels); + } + + /** + * Return the current tick caption + * + * @return string + */ + public function key() + { + return current($this->labels); + } + + /** + * Return true when the iterator is in a valid range + * + * @return bool + */ + public function valid() + { + return current($this->labels) !== false; + } + + /** + * Rewind the internal array + */ + public function rewind() + { + reset($this->labels); + } +} diff --git a/library/Icinga/Chart/Unit/LinearUnit.php b/library/Icinga/Chart/Unit/LinearUnit.php new file mode 100644 index 0000000..ea4792b --- /dev/null +++ b/library/Icinga/Chart/Unit/LinearUnit.php @@ -0,0 +1,227 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Unit; + +/** + * Linear tick distribution over the axis + */ +class LinearUnit implements AxisUnit +{ + /** + * The minimum value to display + * + * @var int + */ + protected $min; + + /** + * The maximum value to display + * + * @var int + */ + protected $max; + + /** + * True when the minimum value is static and isn't affected by the dataset + * + * @var bool + */ + protected $staticMin = false; + + /** + * True when the maximum value is static and isn't affected by the dataset + * + * @var bool + */ + protected $staticMax = false; + + /** + * The number of ticks to use + * + * @var int + */ + protected $nrOfTicks = 10; + + /** + * The currently displayed tick + * + * @var int + */ + protected $currentTick = 0; + + /** + * The currently displayed value + * @var int + */ + protected $currentValue = 0; + + /** + * Create and initialize this AxisUnit + * + * @param int $nrOfTicks The number of ticks to use + */ + public function __construct($nrOfTicks = 10) + { + $this->min = PHP_INT_MAX; + $this->max = ~PHP_INT_MAX; + $this->nrOfTicks = $nrOfTicks; + } + + /** + * Add a dataset and calculate the minimum and maximum value for this AxisUnit + * + * @param array $dataset The dataset to add + * @param int $idx The idx (0 for x, 1 for y) + * + * @return $this Fluent interface + */ + public function addValues(array $dataset, $idx = 0) + { + $datapoints = array(); + + foreach ($dataset['data'] as $points) { + $datapoints[] = $points[$idx]; + } + if (empty($datapoints)) { + return $this; + } + sort($datapoints); + if (!$this->staticMax) { + $this->max = max($this->max, $datapoints[count($datapoints) - 1]); + } + if (!$this->staticMin) { + $this->min = min($this->min, $datapoints[0]); + } + $this->currentTick = 0; + $this->currentValue = $this->min; + if ($this->max === $this->min) { + $this->max = $this->min + 10; + } + $this->nrOfTicks = $this->max - $this->min; + return $this; + } + + /** + * Transform the absolute value to an axis relative value + * + * @param int $value The absolute coordinate from the dataset + * @return float|int The axis relative coordinate (between 0 and 100) + */ + public function transform($value) + { + if ($value < $this->min) { + return 0; + } elseif ($value > $this->max) { + return 100; + } else { + return 100 * ($value - $this->min) / $this->nrOfTicks; + } + } + + /** + * Return the position of the current tick + * + * @return int + */ + #[\ReturnTypeWillChange] + public function current() + { + return $this->currentTick; + } + + /** + * Calculate the next tick and tick value + */ + public function next(): void + { + $this->currentTick += (100 / $this->nrOfTicks); + $this->currentValue += (($this->max - $this->min) / $this->nrOfTicks); + } + + /** + * Return the label for the current tick + * + * @return string The label for the current tick + */ + #[\ReturnTypeWillChange] + public function key() + { + return (string) intval($this->currentValue); + } + + /** + * True when we're at a valid tick (iterator interface) + * + * @return bool + */ + public function valid(): bool + { + return $this->currentTick >= 0 && $this->currentTick <= 100; + } + + /** + * Reset the current tick and label value + */ + public function rewind(): void + { + $this->currentTick = 0; + $this->currentValue = $this->min; + } + + /** + * Set the axis maximum value to a fixed value + * + * @param int $max The new maximum value + */ + public function setMax($max) + { + if ($max !== null) { + $this->max = $max; + $this->staticMax = true; + } + } + + /** + * Set the axis minimum value to a fixed value + * + * @param int $min The new minimum value + */ + public function setMin($min) + { + if ($min !== null) { + $this->min = $min; + $this->staticMin = true; + } + } + + /** + * Return the current minimum value of the axis + * + * @return int The minimum set for this axis + */ + public function getMin() + { + return $this->min; + } + + /** + * Return the current maximum value of the axis + * + * @return int The maximum set for this axis + */ + public function getMax() + { + return $this->max; + } + + /** + * Get the amount of ticks necessary to display this AxisUnit + * + * @return int + */ + public function getTicks() + { + return $this->nrOfTicks; + } +} diff --git a/library/Icinga/Chart/Unit/LogarithmicUnit.php b/library/Icinga/Chart/Unit/LogarithmicUnit.php new file mode 100644 index 0000000..90cad57 --- /dev/null +++ b/library/Icinga/Chart/Unit/LogarithmicUnit.php @@ -0,0 +1,263 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Unit; + +/** + * Logarithmic tick distribution over the axis + * + * This class does not use the actual logarithm, but a slightly altered version called the + * Log-Modulo transformation. This is necessary, since a regular logarithmic scale is not able to display negative + * values and zero-points. See <a href="http://blogs.sas.com/content/iml/2014/07/14/log-transformation-of-pos-neg> + * this article </a> for a more detailed description. + */ +class LogarithmicUnit implements AxisUnit +{ + /** + * @var int + */ + protected $base; + + /** + * @var + */ + protected $currentTick; + + /** + * @var + */ + protected $minExp; + + /** + * @var + */ + protected $maxExp; + + /** + * True when the minimum value is static and isn't affected by the data set + * + * @var bool + */ + protected $staticMin = false; + + /** + * True when the maximum value is static and isn't affected by the data set + * + * @var bool + */ + protected $staticMax = false; + + /** + * Create and initialize this AxisUnit + * + * @param int $nrOfTicks The number of ticks to use + */ + public function __construct($base = 10) + { + $this->base = $base; + $this->minExp = PHP_INT_MAX; + $this->maxExp = ~PHP_INT_MAX; + } + + /** + * Add a dataset and calculate the minimum and maximum value for this AxisUnit + * + * @param array $dataset The dataset to add + * @param int $idx The idx (0 for x, 1 for y) + * + * @return $this Fluent interface + */ + public function addValues(array $dataset, $idx = 0) + { + $datapoints = array(); + + foreach ($dataset['data'] as $points) { + $datapoints[] = $points[$idx]; + } + if (empty($datapoints)) { + return $this; + } + sort($datapoints); + if (!$this->staticMax) { + $this->maxExp = max($this->maxExp, $this->logCeil($datapoints[count($datapoints) - 1])); + } + if (!$this->staticMin) { + $this->minExp = min($this->minExp, $this->logFloor($datapoints[0])); + } + $this->currentTick = 0; + + return $this; + } + + /** + * Transform the absolute value to an axis relative value + * + * @param int $value The absolute coordinate from the data set + * @return float|int The axis relative coordinate (between 0 and 100) + */ + public function transform($value) + { + if ($value < $this->pow($this->minExp)) { + return 0; + } elseif ($value > $this->pow($this->maxExp)) { + return 100; + } else { + return 100 * ($this->log($value) - $this->minExp) / $this->getTicks(); + } + } + + /** + * Return the position of the current tick + * + * @return int + */ + public function current() + { + return $this->currentTick * (100 / $this->getTicks()); + } + + /** + * Calculate the next tick and tick value + */ + public function next() + { + ++ $this->currentTick; + } + + /** + * Return the label for the current tick + * + * @return string The label for the current tick + */ + public function key() + { + $currentBase = $this->currentTick + $this->minExp; + if (abs($currentBase) > 4) { + return $this->base . 'E' . $currentBase; + } + return (string) intval($this->pow($currentBase)); + } + + /** + * True when we're at a valid tick (iterator interface) + * + * @return bool + */ + public function valid() + { + return $this->currentTick >= 0 && $this->currentTick < $this->getTicks(); + } + + /** + * Reset the current tick and label value + */ + public function rewind() + { + $this->currentTick = 0; + } + + /** + * Perform a log-modulo transformation + * + * @param $value The value to transform + * + * @return double The transformed value + */ + protected function log($value) + { + $sign = $value > 0 ? 1 : -1; + return $sign * log1p($sign * $value) / log($this->base); + } + + /** + * Calculate the biggest exponent necessary to display the given data point + * + * @param $value + * + * @return float + */ + protected function logCeil($value) + { + return ceil($this->log($value)) + 1; + } + + /** + * Calculate the smallest exponent necessary to display the given data point + * + * @param $value + * + * @return float + */ + protected function logFloor($value) + { + return floor($this->log($value)); + } + + /** + * Inverse function to the log-modulo transformation + * + * @param $value + * + * @return double + */ + protected function pow($value) + { + if ($value == 0) { + return 0; + } + $sign = $value > 0 ? 1 : -1; + return $sign * (pow($this->base, $sign * $value)); + } + + /** + * Set the axis minimum value to a fixed value + * + * @param int $min The new minimum value + */ + public function setMin($min) + { + $this->minExp = $this->logFloor($min); + $this->staticMin = true; + } + + /** + * Set the axis maximum value to a fixed value + * + * @param int $max The new maximum value + */ + public function setMax($max) + { + $this->maxExp = $this->logCeil($max); + $this->staticMax = true; + } + + /** + * Return the current minimum value of the axis + * + * @return int The minimum set for this axis + */ + public function getMin() + { + return $this->pow($this->minExp); + } + + /** + * Return the current maximum value of the axis + * + * @return int The maximum set for this axis + */ + public function getMax() + { + return $this->pow($this->maxExp); + } + + /** + * Get the amount of ticks necessary to display this AxisUnit + * + * @return int + */ + public function getTicks() + { + return $this->maxExp - $this->minExp; + } +} diff --git a/library/Icinga/Chart/Unit/StaticAxis.php b/library/Icinga/Chart/Unit/StaticAxis.php new file mode 100644 index 0000000..d563091 --- /dev/null +++ b/library/Icinga/Chart/Unit/StaticAxis.php @@ -0,0 +1,129 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Unit; + +class StaticAxis implements AxisUnit +{ + private $items = array(); + + /** + * Add a dataset to this AxisUnit, required for dynamic min and max values + * + * @param array $dataset The dataset that will be shown in the Axis + * @param int $idx The idx in the dataset (0 for x, 1 for y) + * + * @return $this Fluent interface + */ + public function addValues(array $dataset, $idx = 0) + { + $datapoints = array(); + foreach ($dataset['data'] as $points) { + $this->items[] = $points[$idx]; + } + $this->items = array_unique($this->items); + + return $this; + } + + /** + * Transform the given absolute value in an axis relative value + * + * @param int $value The absolute, dataset dependent value + * + * @return int An axis relative value + */ + public function transform($value) + { + $flipped = array_flip($this->items); + if (!isset($flipped[$value])) { + return 0; + } + $pos = $flipped[$value]; + return 1 + (99 / count($this->items) * $pos); + } + /** + * Set the axis minimum value to a fixed value + * + * @param int $min The new minimum value + */ + public function setMin($min) + { + } + + /** + * Set the axis maximum value to a fixed value + * + * @param int $max The new maximum value + */ + public function setMax($max) + { + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Return the current element + * @link http://php.net/manual/en/iterator.current.php + * @return mixed Can return any type. + */ + public function current() + { + return 1 + (99 / count($this->items) * key($this->items)); + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Move forward to next element + * @link http://php.net/manual/en/iterator.next.php + * @return void Any returned value is ignored. + */ + public function next() + { + return next($this->items); + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Return the key of the current element + * @link http://php.net/manual/en/iterator.key.php + * @return mixed scalar on success, or null on failure. + */ + public function key() + { + return current($this->items); + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Checks if current position is valid + * @link http://php.net/manual/en/iterator.valid.php + * @return boolean The return value will be casted to boolean and then evaluated. + * Returns true on success or false on failure. + */ + public function valid() + { + return current($this->items) !== false; + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Rewind the Iterator to the first element + * @link http://php.net/manual/en/iterator.rewind.php + * @return void Any returned value is ignored. + */ + public function rewind() + { + return reset($this->items); + } + + /** + * Get the amount of ticks of this axis + * + * @return int + */ + public function getTicks() + { + return count($this->items); + } +} |