Browse Source

Add meter bar.

mscherer 6 years ago
parent
commit
9bcdeb6b1b

+ 1 - 0
README.md

@@ -29,6 +29,7 @@ This master branch only works for **CakePHP 3.7+** - please use the 2.x branch f
 - MultiColumnAuthenticate for log-in with e.g. "email or username".
 - Slugged, Reset and other behaviors
 - Tree helper for working with (complex) trees and their output.
+- Progress and Meter helper for progress bar and meter bar elements (HTML5 and textual).
 - Text, Time, Number libs and helpers etc provide extended functionality if desired.
 - QrCode, Gravatar and other useful small helpers
 - Timeline, Typography, etc provide additional helper functionality.

+ 68 - 0
docs/Helper/Meter.md

@@ -0,0 +1,68 @@
+# Meter Helper
+
+A CakePHP helper to handle gauge calculation and output as meter (bar) element.
+By default it supports HTML5 meter element - and as alternative or fallback uses unicode chars to work completely text-based.
+
+The main advantage of the meter helper over default calculation is that you can decide on the overflow of min/max boundaries.
+By default the max/min borders are kept and the value just cut to this boundary value.
+
+Use the meter element to display data within a given range (a gauge).
+Examples: Disk usage, the relevance of a query result, etc. Fixed values basically.
+
+Note: The `<meter>` tag should not be used to indicate progress (as in a progress bar). Use Progress helper here.
+
+## Setup
+Include helper in your AppView class as
+```php
+$this->addHelper('Tools.Meter', [
+    ...
+]);
+```
+
+You can store default configs also in Configure key `'Meter'`.
+Mainly empty/full chars can be configured this way.
+
+## Usage
+
+### htmlMeterBar()
+Displays HTML5 element.
+This is best used with the textual fallback if you are not sure everyone is using a modern browser.
+See [browser support](https://www.w3schools.com/tags/tag_meter.asp).
+
+```php
+echo $this->Meter->htmlMeterBar(
+    $value,
+    $max,
+    $min,
+    $options, 
+    $attributes
+);
+```
+
+### meterBar()
+Display a text-based progress bar with the progress in percentage as title.
+```php
+echo $this->Meter->meterBar(
+    $value,
+    $max,
+    $min,
+    $length, // Char length >= 3
+    $options, 
+    $attributes
+);
+```
+
+### draw()
+Display a text-based progress bar as raw bar.
+```php
+echo $this->Meter->draw(
+    $percentage // Value 0...1
+    $length // Char length >= 3 
+);
+```
+This can be used if you want to customize the usage.
+
+## Tips
+
+Consider using CSS `white-space: nowrap` for the span tag if wrapping could occur to the textual version based on smaller display sizes.
+Wrapping would render such a text-based progress bar a bit hard to read.

+ 11 - 2
docs/Helper/Progress.md

@@ -1,13 +1,17 @@
 # Progress Helper
 
 A CakePHP helper to handle basic progress calculation and output.
-By default it uses unicode chars to work completely text-based.
+By default it supports HTML5 progress element - and as alternative or fallback uses unicode chars to work completely text-based.
 
 The main advantage of the progress helper over default round() calculation is that it only fully displays
 0 and 100 percent borders (including the char icon representation) if truly fully that min/max value.
 So for `0.9999` as well as `0.0001` etc it will not yet display the completely full or empty bar.
 If you want that, you need to pre-round before passing it in.
 
+Tip: Use the `<progress>` tag in conjunction with JavaScript to display the progress of a task.
+
+Note: The `<progress>` tag is not suitable for representing a gauge (e.g. disk space usage or relevance of a query result).
+ To represent a gauge, use the Meter helper instead.
 
 ## Setup
 Include helper in your AppView class as
@@ -22,6 +26,11 @@ Mainly empty/full chars can be configured this way.
 
 ## Usage
 
+### htmlProgressBar()
+Displays HTML5 element.
+This is best used with the textual fallback if you are not sure everyone is using a modern browser.
+See [browser support](https://www.w3schools.com/tags/tag_progress.asp).
+
 ### progressBar()
 Display a text-based progress bar with the progress in percentage as title.
 ```php
@@ -64,5 +73,5 @@ And of course `0.99999` should still be "only" `99%`.
 
 ## Tips
 
-Consider using CSS `white-space: nowrap` for the span tag if wrapping could occur based on smaller display sizes.
+Consider using CSS `white-space: nowrap` for the span tag if wrapping could occur to the textual version based on smaller display sizes.
 Wrapping would render such a text-based progress bar a bit hard to read.

+ 1 - 0
docs/README.md

@@ -49,6 +49,7 @@ Helpers:
 * [Common](Helper/Common.md)
 * [Format](Helper/Format.md)
 * [Progress](Helper/Progress.md)
+* [Meter](Helper/Meter.md)
 * [Tree](Helper/Tree.md)
 * [Typography](Helper/Typography.md)
 

+ 277 - 0
src/View/Helper/MeterHelper.php

@@ -0,0 +1,277 @@
+<?php
+
+namespace Tools\View\Helper;
+
+use Cake\Core\Configure;
+use Cake\View\Helper;
+use Cake\View\View;
+use InvalidArgumentException;
+use Tools\Utility\Number;
+
+/**
+ * Use the meter element to display data within a given range (a gauge).
+ *
+ * Examples: Disk usage, the relevance of a query result, etc. Fixed values.
+ *
+ * Note: The <meter> tag should not be used to indicate progress (as in a progress bar). Use Progress helper here.
+ *
+ * @author Mark Scherer
+ * @license MIT
+ * @property \Cake\View\Helper\HtmlHelper $Html
+ */
+class MeterHelper extends Helper {
+
+	const LENGTH_MIN = 3;
+	const CHAR_EMPTY = '░';
+	const CHAR_FULL = '█';
+
+	/**
+	 * @var array
+	 */
+	public $helpers = ['Html'];
+
+	/**
+	 * @var array
+	 */
+	protected $_defaults = [
+		'empty' => self::CHAR_EMPTY,
+		'full' => self::CHAR_FULL,
+		'precision' => 6,
+	];
+
+	/**
+	 * @param \Cake\View\View $View
+	 * @param array $config
+	 */
+	public function __construct(View $View, array $config = []) {
+		$defaults = (array)Configure::read('Meter') + $this->_defaults;
+		$config += $defaults;
+
+		parent::__construct($View, $config);
+	}
+
+	/**
+	 * Creates HTML5 meter element.
+	 *
+	 * Note: This requires a textual fallback for IE12 and below.
+	 *
+	 * Options:
+	 * - fallbackHtml: Use a fallback string if the browser cannot display this type of HTML5 element
+	 * - overflow: Set to true to allow the value to move the max/min boundaries
+	 *
+	 * @param float $value
+	 * @param float $max
+	 * @param float|null $min
+	 * @param array $options
+	 * @param array $attributes
+	 * @return string
+	 */
+	public function htmlMeterBar($value, $max, $min = null, array $options = [], array $attributes = []) {
+		$defaults = [
+			'fallbackHtml' => null,
+			'overflow' => false,
+		];
+		$options += $defaults;
+
+		$value = $this->prepareValue($value, $max, $min, $options['overflow']);
+		$max = $this->prepareMax($value, $max, $options['overflow']);
+		$min = $this->prepareMax($value, $min, $options['overflow']);
+
+		$progress = $this->calculatePercentage($max - $min, $value - $min);
+
+		$attributes += [
+			'value' => $value,
+			'min' => $min === null ? 0 : $min,
+			'max' => $max,
+			'title' => Number::toPercentage($progress, 0, ['multiply' => true]),
+		];
+
+		$fallback = '';
+		if ($options['fallbackHtml']) {
+			$fallback = $options['fallbackHtml'];
+		}
+
+		return $this->Html->tag('meter', $fallback, $attributes);
+	}
+
+	/**
+	 * @param float|int $total
+	 * @param float|int $is
+	 * @return float
+	 */
+	protected function calculatePercentage($total, $is) {
+		$percentage = $total ? $is / $total : 0.0;
+
+		return $percentage;
+	}
+
+	/**
+	 * Creates text based meter element.
+	 *
+	 * @param float $value
+	 * @param float $max
+	 * @param float $min
+	 * @param int $length As char count
+	 * @param array $options
+	 * @param array $attributes
+	 * @return string
+	 */
+	public function meterBar($value, $max, $min, $length, array $options = [], array $attributes = []) {
+		$defaults = [
+			'overflow' => false,
+		];
+		$options += $defaults;
+
+		$value = $this->prepareValue($value, $max, $min, $options['overflow']);
+		$max = $this->prepareMax($value, $max, $options['overflow']);
+		$min = $this->prepareMin($value, $min, $options['overflow']);
+
+		$progress = $this->calculatePercentage($max - $min, $value - $min);
+		$bar = $this->draw($progress, $length);
+
+		$attributes += [
+			'title' => Number::toPercentage($progress, 0, ['multiply' => true]),
+		];
+
+		return $this->Html->tag('span', $bar, $attributes);
+	}
+
+	/**
+	 * Render the progress bar based on the current state.
+	 *
+	 * @param float $complete
+	 * @param int $length
+	 * @return string
+	 * @throws \InvalidArgumentException
+	 */
+	public function draw($complete, $length) {
+		if ($length < static::LENGTH_MIN) {
+			throw new InvalidArgumentException('Min length for such a progress bar is ' . static::LENGTH_MIN);
+		}
+
+		$barLength = $this->calculateBarLength($complete, $length);
+
+		$bar = '';
+		if ($barLength > 0) {
+			$bar = str_repeat($this->getConfig('full'), $barLength);
+		}
+
+		$pad = $length - $barLength;
+		if ($pad > 0) {
+			$bar .= str_repeat($this->getConfig('empty'), $pad);
+		}
+
+		return $bar;
+	}
+
+	/**
+	 * @param float $complete Value between 0 and 1.
+	 * @param int $length
+	 * @return int
+	 */
+	protected function calculateBarLength($complete, $length) {
+		$barLength = (int)round($length * $complete, 0);
+
+		return $barLength;
+	}
+
+	/**
+	 * Prepares the input value based on max/min and if
+	 * - overflow: adjust the min/max by value
+	 * - not overflow: adjust the value by min/max
+	 *
+	 * Also: Rounds as per reasonable precision based on exponent
+	 *
+	 * @param float $value
+	 * @param float $max
+	 * @param float $min
+	 * @param bool $overflow
+	 * @return float
+	 * @throws \InvalidArgumentException
+	 */
+	protected function prepareValue($value, $max, $min, $overflow) {
+		if ($max < $min) {
+			throw new InvalidArgumentException('Max needs to be larger than Min.');
+		}
+
+		if ($value > $max && !$overflow) {
+			$value = $max;
+		}
+		if ($value < $min && !$overflow) {
+			$value = $min;
+		}
+
+		return $this->roundValue($value);
+	}
+
+	/**
+	 * Prepares the max value
+	 * - overflow: adjust the min/max by value
+	 * - not overflow: adjust the value by min/max
+	 *
+	 * Also: Rounds as per reasonable precision based on exponent
+	 *
+	 * @param float $value
+	 * @param float $max
+	 * @param bool $overflow
+	 * @return float
+	 */
+	protected function prepareMax($value, $max, $overflow) {
+		if ($value <= $max) {
+			return $max;
+		}
+
+		if ($overflow) {
+			return $value;
+		}
+
+		return $max;
+	}
+
+	/**
+	 * Prepares the min value
+	 * - overflow: adjust the min/max by value
+	 * - not overflow: adjust the value by min/max
+	 *
+	 * Also: Rounds as per reasonable precision based on exponent
+	 *
+	 * @param float $value
+	 * @param float $min
+	 * @param bool $overflow
+	 * @return float
+	 */
+	protected function prepareMin($value, $min, $overflow) {
+		if ($value > $min) {
+			return $min;
+		}
+
+		if ($overflow) {
+			return $value;
+		}
+
+		return $min;
+	}
+
+	/**
+	 * @param float $value
+	 *
+	 * @return float
+	 */
+	protected function roundValue($value)
+	{
+		$precision = (int)$this->getConfig('precision');
+
+		$string = (string)$value;
+		if ($precision === -1 || strlen($string) < $precision) {
+			return $value;
+		}
+
+		$separatorIndex = strpos($string,'.');
+		$positive = $separatorIndex ?: 0;
+
+		$left = $precision - $positive;
+
+		return round($value, $left);
+	}
+
+}

+ 43 - 2
src/View/Helper/ProgressHelper.php

@@ -9,6 +9,13 @@ use InvalidArgumentException;
 use Tools\Utility\Number;
 
 /**
+ * The progress element represents the progress of a task.
+ *
+ * Tip: Use the <progress> tag in conjunction with JavaScript to display the progress of a task.
+ *
+ * Note: The <progress> tag is not suitable for representing a gauge (e.g. disk space usage or relevance of a query result).
+ * To represent a gauge, use the Meter helper instead.
+ *
  * @author Mark Scherer
  * @license MIT
  * @property \Cake\View\Helper\HtmlHelper $Html
@@ -44,6 +51,40 @@ class ProgressHelper extends Helper {
 	}
 
 	/**
+	 * Creates HTML5 progress element.
+	 *
+	 * Note: This requires a textual fallback for IE9 and below.
+	 *
+	 * Options:
+	 *
+	 * @param float $value Value 0...1
+	 * @param array $options
+	 * @param array $attributes
+	 * @return string
+	 */
+	public function htmlProgressBar($value, array $options = [], array $attributes = []) {
+		$defaults = [
+			'fallbackHtml' => null,
+		];
+		$options += $defaults;
+
+		$progress = $this->roundPercentage($value);
+
+		$attributes += [
+			'value' => number_format($progress * 100, 0),
+			'max' => '100',
+			'title' => Number::toPercentage($progress, 0, ['multiply' => true]),
+		];
+
+		$fallback = '';
+		if ($options['fallbackHtml']) {
+			$fallback = $options['fallbackHtml'];
+		}
+
+		return $this->Html->tag('progress', $fallback, $attributes);
+	}
+
+	/**
 	 * @param float $value Value 0...1
 	 * @param int $length As char count
 	 * @param array $attributes
@@ -62,8 +103,8 @@ class ProgressHelper extends Helper {
 	/**
 	 * Render the progress bar based on the current state.
 	 *
-	 * @param float $complete
-	 * @param int $length
+	 * @param float $complete Value between 0 and 1.
+	 * @param int $length Bar length.
 	 * @return string
 	 * @throws \InvalidArgumentException
 	 */

+ 178 - 0
tests/TestCase/View/Helper/MeterHelperTest.php

@@ -0,0 +1,178 @@
+<?php
+
+namespace Tools\Test\TestCase\View\Helper;
+
+use Cake\View\View;
+use InvalidArgumentException;
+use Tools\TestSuite\TestCase;
+use Tools\TestSuite\ToolsTestTrait;
+use Tools\View\Helper\MeterHelper;
+
+class MeterHelperTest extends TestCase {
+
+	use ToolsTestTrait;
+
+	/**
+	 * @var \Cake\View\View
+	 */
+	protected $View;
+
+	/**
+	 * @var \Tools\View\Helper\MeterHelper
+	 */
+	protected $meterHelper;
+
+	/**
+	 * @return void
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		$this->View = new View(null);
+		$this->meterHelper = new MeterHelper($this->View);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testPrepareValue() {
+		$value = 11.1;
+		$max = 13.0;
+		$min = 11.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareValue', [$value, $max, $min, false]);
+
+		$this->assertSame($value, $result);
+
+		$max = 11.0;
+		$min = 10.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareValue', [$value, $max, $min, false]);
+
+		$this->assertSame($max, $result);
+
+		$max = 13.0;
+		$min = 12.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareValue', [$value, $max, $min, false]);
+
+		$this->assertSame($min, $result);
+
+		$max = 10.0;
+		$min = 9.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareValue', [$value, $max, $min, true]);
+
+		$this->assertSame($value, $result);
+
+		$max = 13.0;
+		$min = 12.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareValue', [$value, $max, $min, true]);
+
+		$this->assertSame($value, $result);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testPrepareMax() {
+		$value = 11.1;
+		$max = 13.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareMax', [$value, $max, false]);
+
+		$this->assertSame($max, $result);
+
+		$max = 11.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareMax', [$value, $max, false]);
+
+		$this->assertSame($max, $result);
+
+		$max = 10.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareMax', [$value, $max, true]);
+
+		$this->assertSame($value, $result);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testPrepareMin() {
+		$value = 11.1;
+		$min = 10.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareMin', [$value, $min, false]);
+
+		$this->assertSame($min, $result);
+
+		$min = 12.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareMin', [$value, $min, false]);
+
+		$this->assertSame($min, $result);
+
+		$min = 12.0;
+		$result = $this->invokeMethod($this->meterHelper, 'prepareMin', [$value, $min, true]);
+
+		$this->assertSame($value, $result);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testDraw() {
+		$result = $this->meterHelper->draw(0.00, 3);
+		$this->assertSame('░░░', $result);
+
+		$result = $this->meterHelper->draw(1.00, 3);
+		$this->assertSame('███', $result);
+
+		$result = $this->meterHelper->draw(0.50, 3);
+		$this->assertSame('██░', $result);
+
+		$result = $this->meterHelper->draw(0.30, 5);
+		$this->assertSame('██░░░', $result);
+
+		$result = $this->meterHelper->draw(0.01, 3);
+		$this->assertSame('░░░', $result);
+
+		$result = $this->meterHelper->draw(0.99, 3);
+		$this->assertSame('███', $result);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testHtmlMeterBar() {
+		$result = $this->meterHelper->htmlMeterBar(40 / 3, 20, 5);
+		$expected = '<meter value="13.3333" min="5" max="20" title="56%"></meter>';
+		$this->assertSame($expected, $result);
+
+		$result = $this->meterHelper->htmlMeterBar(-1, 2, 0);
+		$expected = '<meter value="0" min="0" max="2" title="0%"></meter>';
+		$this->assertSame($expected, $result);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testMeterBar() {
+		$result = $this->meterHelper->meterBar(0.001, 1, 0, 3);
+		$this->assertSame('<span title="0%">░░░</span>', $result);
+
+		$result = $this->meterHelper->meterBar(2.1, 10, -10, 3);
+		$this->assertSame('<span title="60%">██░</span>', $result);
+
+		$result = $this->meterHelper->meterBar(0.000, 1, 0, 3);
+		$this->assertSame('<span title="0%">░░░</span>', $result);
+
+		$result = $this->meterHelper->meterBar(98, 100, -100, 3);
+		$this->assertSame('<span title="99%">███</span>', $result);
+
+		$result = $this->meterHelper->meterBar(1.000, 1, 0, 3);
+		$this->assertSame('<span title="100%">███</span>', $result);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testMeterBarInvalid() {
+		$this->expectException(InvalidArgumentException::class);
+
+		$this->meterHelper->meterBar(1, -1, 1, 3);
+	}
+
+}