// SPDX-License-Identifier: GPL-3.0-or-later 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; } }