From 1277f8f96f334260d278b5bc81f9229ea9b9c319 Mon Sep 17 00:00:00 2001
From: Michael Weimann
Date: Tue, 7 Jun 2022 22:59:49 +0200
Subject: [PATCH] Replace chart-js with backend rendering
Signed-off-by: Michael Weimann
---
includes/helper/graph_helper.php | 46 -----
includes/includes.php | 1 -
includes/pages/admin_arrive.php | 13 +-
package.json | 1 -
resources/assets/js/vendor.js | 1 -
resources/assets/themes/barchart.scss | 122 +++++++++++++
resources/assets/themes/base.scss | 1 +
resources/views/components/barchart.twig | 33 ++++
resources/views/pages/design.twig | 3 +
src/Controllers/DesignController.php | 2 +
src/Helpers/BarChart.php | 167 ++++++++++++++++++
.../Unit/Controllers/DesignControllerTest.php | 9 +
tests/Unit/Helpers/BarChartTest.php | 134 ++++++++++++++
tests/Unit/TestCase.php | 32 ++++
yarn.lock | 32 +---
15 files changed, 512 insertions(+), 85 deletions(-)
delete mode 100644 includes/helper/graph_helper.php
create mode 100644 resources/assets/themes/barchart.scss
create mode 100644 resources/views/components/barchart.twig
create mode 100644 src/Helpers/BarChart.php
create mode 100644 tests/Unit/Helpers/BarChartTest.php
diff --git a/includes/helper/graph_helper.php b/includes/helper/graph_helper.php
deleted file mode 100644
index a4bcb773..00000000
--- a/includes/helper/graph_helper.php
+++ /dev/null
@@ -1,46 +0,0 @@
- $name) {
- $values = [];
- foreach ($data as $dataset) {
- $values[] = $dataset[$row_key];
- }
- $datasets[] = [
- 'label' => $name,
- 'backgroundColor' => $colors[$row_key],
- 'data' => $values
- ];
- }
-
- return '
- ';
-}
diff --git a/includes/includes.php b/includes/includes.php
index 29eedfcc..149adf3f 100644
--- a/includes/includes.php
+++ b/includes/includes.php
@@ -55,7 +55,6 @@ $includeFiles = [
__DIR__ . '/../includes/controller/user_driver_licenses_controller.php',
__DIR__ . '/../includes/controller/user_worklog_controller.php',
- __DIR__ . '/../includes/helper/graph_helper.php',
__DIR__ . '/../includes/helper/legacy_helper.php',
__DIR__ . '/../includes/helper/message_helper.php',
__DIR__ . '/../includes/helper/email_helper.php',
diff --git a/includes/pages/admin_arrive.php b/includes/pages/admin_arrive.php
index 15cee8dd..df29f2e0 100644
--- a/includes/pages/admin_arrive.php
+++ b/includes/pages/admin_arrive.php
@@ -1,5 +1,6 @@
__('arrived'),
'sum' => __('arrived sum')
], [
@@ -208,8 +209,8 @@ function admin_arrive()
], $planned_arrival_at_day)
]),
div('col-md-4', [
- heading(__('Arrival statistics'), 2),
- bargraph('arrives', 'day', [
+ heading(__('Arrival statistics'), 3),
+ BarChart::render([
'count' => __('arrived'),
'sum' => __('arrived sum')
], [
@@ -223,8 +224,8 @@ function admin_arrive()
], $arrival_at_day)
]),
div('col-md-4', [
- heading(__('Planned departure statistics'), 2),
- bargraph('planned_departures', 'day', [
+ heading(__('Planned departure statistics'), 3),
+ BarChart::render([
'count' => __('arrived'),
'sum' => __('arrived sum')
], [
diff --git a/package.json b/package.json
index 4c792912..4d60ffad 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,6 @@
"dependencies": {
"@popperjs/core": "^2.9.2",
"bootstrap": "^5.1.0",
- "chart.js": "^2.9.3",
"core-js": "^3.6.5",
"jquery": "^3.5.1",
"jquery-ui": "^1.13.0",
diff --git a/resources/assets/js/vendor.js b/resources/assets/js/vendor.js
index df9b380d..9f262ec7 100644
--- a/resources/assets/js/vendor.js
+++ b/resources/assets/js/vendor.js
@@ -4,7 +4,6 @@ require('jquery-ui');
window.bootstrap = require('bootstrap');
window.moment = require('moment');
require('moment/locale/de');
-require('chart.js');
require('./forms');
require('./sticky-headers');
require('./moment-countdown');
diff --git a/resources/assets/themes/barchart.scss b/resources/assets/themes/barchart.scss
new file mode 100644
index 00000000..26e305ae
--- /dev/null
+++ b/resources/assets/themes/barchart.scss
@@ -0,0 +1,122 @@
+.barchart {
+ --barchart-padding-left: 50px;
+ --barchart-bar-margin: 2%;
+ --barchart-group-margin: 2%;
+ --barchart-label-font-size: 14px;
+
+ height: 300px;
+ margin-bottom: map-get($spacers, 3);
+ margin-top: map-get($spacers, 4);
+ position: relative;
+ // enable printing backgrounds
+ print-color-adjust: exact;
+
+ &-20 {
+ // >= 20 bars
+ --barchart-group-margin: .75%;
+ }
+
+ &-40 {
+ // >= 40 bars
+ --barchart-bar-margin: 1px;
+ --barchart-bar-margin: .5px;
+ --barchart-group-margin: .5%;
+ }
+
+ &-50 {
+ // >= 50 bars
+ --barchart-bar-margin: .5px;
+ --barchart-group-margin: 0.2%;
+
+ .barchart-group:nth-child(2n) .barchart-xlabel {
+ // hide every second label
+ display: none;
+ }
+ }
+
+ &-graph-container {
+ align-items: flex-end;
+ display: flex;
+ left: var(--barchart-padding-left);
+ bottom: 75px;
+ position: absolute;
+ right: 0;
+ top: 0;
+ }
+
+ &-group {
+ align-items: flex-end;
+ border-right: 1px solid $gray-400;
+ display: flex;
+ flex-grow: 1;
+ height: 100%;
+ padding-left: var(--barchart-group-margin);
+ padding-right: var(--barchart-group-margin);
+ position: relative;
+
+ &:first-of-type {
+ border-left: 1px solid $gray-400;
+ }
+
+ &:last-of-type {
+ border-right: 1px solid $gray-400;
+ }
+ }
+
+ &-bar {
+ flex-grow: 1;
+ margin-left: var(--barchart-bar-margin);
+ margin-right: var(--barchart-bar-margin);
+ }
+
+ &-ygraph {
+ background-color: $gray-400;
+ height: 1px;
+ left: 0;
+ margin: 0;
+ opacity: 1;
+ position: absolute;
+ right: 0;
+ }
+
+ &-xlabel {
+ bottom: 0;
+ font-size: var(--barchart-label-font-size);
+ left: 50%;
+ line-height: 1;
+ position: absolute;
+ transform-origin: top right;
+ transform: translateY(100%) translateX(-100%) rotate(-45deg);
+ }
+
+ &-ylabel {
+ font-size: var(--barchart-label-font-size);
+ left: calc(-1 * var(--barchart-padding-left));
+ position: absolute;
+ text-align: right;
+ transform: translateY(50%);
+ width: 45px;
+ }
+}
+
+.barchart-legend {
+ margin-bottom: map-get($spacers, 5);
+ padding-left: 50px;
+ // enable printing backgrounds
+ print-color-adjust: exact;
+
+ &-item {
+ align-items: center;
+ border-left-style: solid;
+ border-left-width: 50px;
+ font-size: 14px;
+ display: flex;
+ height: 20px;
+ margin-bottom: 8px;
+ padding-left: 8px;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+ }
+}
diff --git a/resources/assets/themes/base.scss b/resources/assets/themes/base.scss
index 6858d0a7..8cb71267 100644
--- a/resources/assets/themes/base.scss
+++ b/resources/assets/themes/base.scss
@@ -54,6 +54,7 @@ $form-label-font-weight: $font-weight-bold;
@import "~select2/src/scss/core";
@import "~select2-bootstrap-5-theme/src/include-all";
@import "error";
+@import "barchart";
$navbar-height: 3.125rem;
diff --git a/resources/views/components/barchart.twig b/resources/views/components/barchart.twig
new file mode 100644
index 00000000..b851f13b
--- /dev/null
+++ b/resources/views/components/barchart.twig
@@ -0,0 +1,33 @@
+
+
+ {% for yLabel in yLabels %}
+
+
+ {% endfor %}
+ {% for group in groups %}
+
+ {% for bar in group.bars %}
+
+ {% endfor %}
+
{{ group.label }}
+
+ {% endfor %}
+
+
+
+
+ {% for key, color in colors %}
+
+ {{ rowLabels[key] }}
+
+ {% endfor %}
+
diff --git a/resources/views/pages/design.twig b/resources/views/pages/design.twig
index 439532a2..9e049fbb 100644
--- a/resources/views/pages/design.twig
+++ b/resources/views/pages/design.twig
@@ -374,6 +374,9 @@
+
+ Bar Chart
+ {{ bar_chart | raw }}
{% endblock %}
diff --git a/src/Controllers/DesignController.php b/src/Controllers/DesignController.php
index 4df6e7a2..bbb8600c 100644
--- a/src/Controllers/DesignController.php
+++ b/src/Controllers/DesignController.php
@@ -3,6 +3,7 @@
namespace Engelsystem\Controllers;
use Engelsystem\Config\Config;
+use Engelsystem\Helpers\BarChart;
use Engelsystem\Http\Response;
use Engelsystem\Models\User\PersonalData;
use Engelsystem\Models\User\State;
@@ -58,6 +59,7 @@ class DesignController extends BaseController
'demo_user' => $demoUser,
'demo_user_2' => $demoUser2,
'themes' => $themes,
+ 'bar_chart' => BarChart::render(...BarChart::generateChartDemoData(23)),
];
return $this->response->withView(
diff --git a/src/Helpers/BarChart.php b/src/Helpers/BarChart.php
new file mode 100644
index 00000000..134a13b0
--- /dev/null
+++ b/src/Helpers/BarChart.php
@@ -0,0 +1,167 @@
+ $rowLabels Map row key => row label
+ * @param array $colors Map row key => color
+ * @param array $data The chart data group key => [ row name => value ]
+ */
+ public static function render(
+ array $rowLabels,
+ array $colors,
+ array $data
+ ): string {
+ $groupLabels = [];
+ $max = 0;
+
+ foreach ($data as $groupKey => $groupData) {
+ $date = DateTimeImmutable::createFromFormat('Y-m-d', $groupKey);
+ $groupLabels[$groupKey] = $groupKey;
+
+ if ($date) {
+ $groupLabels[$groupKey] = $date->format(__('Y-m-d'));
+ }
+
+ foreach ($rowLabels as $rowKey => $rowName) {
+ $max = max($max, $groupData[$rowKey]);
+ }
+ }
+
+ $roundedMax = (int) ceil($max / 5) * 5;
+ $step = $roundedMax / 5;
+
+ return view('components/barchart', [
+ 'groups' => self::calculateChartGroups(
+ $rowLabels,
+ $colors,
+ $data,
+ $roundedMax,
+ $groupLabels
+ ),
+ 'colors' => $colors,
+ 'rowLabels' => $rowLabels,
+ 'barChartClass' => self::calculateBarChartClass($rowLabels, $data),
+ 'yLabels' => self::calculateYLabels($roundedMax),
+ ]);
+ }
+
+ private static function calculateChartGroups(
+ array $rowLabels,
+ array $colors,
+ array $data,
+ int $max,
+ array $groupLabels
+ ): array {
+ $chartGroups = [];
+
+ foreach ($data as $groupKey => $groupData) {
+ $group = [
+ 'label' => $groupLabels[$groupKey],
+ 'bars' => [],
+ ];
+
+ foreach ($rowLabels as $rowKey => $rowName) {
+ $value = $groupData[$rowKey];
+ $group['bars'][] = [
+ 'value' => $value,
+ 'title' => $group['label'] . "\n" . $rowName . ': ' . $value,
+ 'height' => ($value / $max * 100) . '%',
+ 'bg' => $colors[$rowKey],
+ ];
+ }
+
+ $chartGroups[] = $group;
+ }
+
+ return $chartGroups;
+ }
+
+ /**
+ * @param int $max Max Y value
+ * @return array
+ */
+ private static function calculateYLabels(int $max): array
+ {
+ $step = $max / 5;
+ $yLabels = [];
+
+ for ($y = 0; $y <= $max; $y += $step) {
+ $yLabels[] = [
+ 'label' => $y,
+ 'bottom' => ($y / $max * 100) . '%',
+ ];
+ }
+
+ return $yLabels;
+ }
+
+ private static function calculateBarChartClass(array $rowLabels, array $data): string
+ {
+ $bars = count($data) * count($rowLabels);
+
+ if ($bars >= 50) {
+ return 'barchart-50';
+ }
+
+ if ($bars >= 40) {
+ return 'barchart-40';
+ }
+
+ if ($bars >= 20) {
+ return 'barchart-20';
+ }
+
+ return '';
+ }
+
+ /**
+ * Generates bar chart demo data.
+ *
+ * @param int $days Number of days to generate data for
+ * @return array ready to be passed to BarChart::render
+ */
+ public static function generateChartDemoData(int $days): array
+ {
+ $step = floor(10000 / $days + 1);
+ $now = CarbonImmutable::now();
+ $twoWeeksAgo = $now->subDays($days);
+ $current = $twoWeeksAgo;
+
+ $demoData = [];
+ $count = 1;
+
+ while ($current->isBefore($now)) {
+ $current_key = $current->format('Y-m-d');
+ $demoData[$current_key] = [
+ 'day' => $current_key,
+ 'count' => $step,
+ 'sum' => $step * $count,
+ ];
+ $current = $current->addDay(1);
+ $count++;
+ }
+
+ return [
+ [
+ 'count' => __('arrived'),
+ 'sum' => __('arrived sum'),
+ ],
+ [
+
+ 'count' => '#090',
+ 'sum' => '#888'
+ ],
+ $demoData,
+ ];
+ }
+}
diff --git a/tests/Unit/Controllers/DesignControllerTest.php b/tests/Unit/Controllers/DesignControllerTest.php
index 4e60f793..f3ee06f3 100644
--- a/tests/Unit/Controllers/DesignControllerTest.php
+++ b/tests/Unit/Controllers/DesignControllerTest.php
@@ -2,6 +2,7 @@
namespace Engelsystem\Test\Unit\Controllers;
+use Engelsystem\Application;
use Engelsystem\Config\Config;
use Engelsystem\Controllers\DesignController;
use Engelsystem\Http\Response;
@@ -10,6 +11,14 @@ use PHPUnit\Framework\MockObject\MockObject;
class DesignControllerTest extends TestCase
{
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->mockRenderer();
+ $this->mockTranslator();
+ Application::setInstance($this->app);
+ }
+
/**
* @covers \Engelsystem\Controllers\DesignController::__construct
* @covers \Engelsystem\Controllers\DesignController::index
diff --git a/tests/Unit/Helpers/BarChartTest.php b/tests/Unit/Helpers/BarChartTest.php
new file mode 100644
index 00000000..97cf5251
--- /dev/null
+++ b/tests/Unit/Helpers/BarChartTest.php
@@ -0,0 +1,134 @@
+ 'a label',
+ 'b' => 'b label',
+ ];
+
+ private const COLORS = [
+ 'a' => '#000',
+ 'b' => '#fff',
+ ];
+
+ private const DATA = [
+ '2022-07-11' => [
+ 'day' => '2022-07-11',
+ 'a' => 1,
+ 'b' => 2,
+ ],
+ ];
+
+ /** @var Renderer&MockObject */
+ private $rendererMock;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->rendererMock = $this->mockRenderer(false);
+ $this->mockTranslator();
+ Application::setInstance($this->app);
+ }
+
+ protected function tearDown(): void
+ {
+ Application::setInstance(null);
+ }
+
+ public function testRender(): void
+ {
+ $this->rendererMock->expects(self::once())
+ ->method('render')
+ ->with('components/barchart', [
+ 'groups' => [
+ [
+ 'bars' => [
+ [
+ 'value' => 1,
+ 'title' => "2022-07-11\na label: 1",
+ 'height' => '20%',
+ 'bg' => '#000',
+ ],
+ [
+ 'value' => 2,
+ 'title' => "2022-07-11\nb label: 2",
+ 'height' => '40%',
+ 'bg' => '#fff',
+ ],
+ ],
+ 'label' => '2022-07-11',
+ ],
+ ],
+ 'colors' => self::COLORS,
+ 'rowLabels' => self::ROW_LABELS,
+ 'barChartClass' => '',
+ 'yLabels' => [
+ [
+ 'label' => '0',
+ 'bottom' => '0%',
+ ],
+ [
+ 'label' => '1',
+ 'bottom' => '20%',
+ ],
+ [
+ 'label' => '2',
+ 'bottom' => '40%',
+ ],
+ [
+ 'label' => '3',
+ 'bottom' => '60%',
+ ],
+ [
+ 'label' => '4',
+ 'bottom' => '80%',
+ ],
+ [
+ 'label' => '5',
+ 'bottom' => '100%',
+ ],
+ ],
+ ])
+ ->willReturn('test bar chart');
+ self::assertSame(
+ 'test bar chart',
+ BarChart::render(self::ROW_LABELS, self::COLORS, self::DATA)
+ );
+ }
+
+ public function provideBarChartClassTestData(): array
+ {
+ return [
+ [10, 'barchart-20'], // number to be passed to BarChart::generateChartDemoData, expected class
+ [20, 'barchart-40'],
+ [25, 'barchart-50'],
+ ];
+ }
+
+ /**
+ * @dataProvider provideBarChartClassTestData
+ */
+ public function testRenderBarChartClass(int $testDataCount, string $expectedClass): void
+ {
+ $this->rendererMock->expects(self::once())
+ ->method('render')
+ ->willReturnCallback(
+ function (string $template, array $data = []) use ($expectedClass) {
+ self::assertSame($expectedClass, $data['barChartClass']);
+ return '';
+ }
+ );
+ BarChart::render(...BarChart::generateChartDemoData($testDataCount));
+ }
+}
diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php
index 9f7bbf61..c975ebdf 100644
--- a/tests/Unit/TestCase.php
+++ b/tests/Unit/TestCase.php
@@ -3,6 +3,7 @@
namespace Engelsystem\Test\Unit;
use Engelsystem\Application;
+use Engelsystem\Renderer\Renderer;
use Faker\Factory as FakerFactory;
use Faker\Generator;
use PHPUnit\Framework\MockObject\MockObject;
@@ -56,4 +57,35 @@ abstract class TestCase extends PHPUnitTestCase
$faker->addProvider(new FakerProvider($faker));
$this->app->instance(Generator::class, $faker);
}
+
+ protected function mockTranslator(): void
+ {
+ $translator = $this->getMockBuilder(Translator::class)
+ ->disableOriginalConstructor()
+ ->setMethods(['translate'])
+ ->getMock();
+ $translator->method('translate')
+ ->willReturnCallback(fn(string $key, array $replace = []) => $key);
+ $this->app->instance('translator', $translator);
+ }
+
+ /**
+ * @param bool $mockImplementation Whether to mock the Renderer methods
+ * @return Renderer&MockObject
+ */
+ protected function mockRenderer(bool $mockImplementation = true): Renderer
+ {
+ $renderer = $this->getMockBuilder(Renderer::class)
+ ->disableOriginalConstructor()
+ ->setMethods(['render'])
+ ->getMock();
+
+ if ($mockImplementation) {
+ $renderer->method('render')
+ ->willReturnCallback(fn(string $template, array $data = []) => $template . json_encode($data));
+ }
+
+ $this->app->instance('renderer', $renderer);
+ return $renderer;
+ }
}
diff --git a/yarn.lock b/yarn.lock
index 943c1fb6..c6ec154c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1304,29 +1304,6 @@ chalk@^2.0.0:
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
-chart.js@^2.9.3:
- version "2.9.4"
- resolved "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz"
- integrity sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==
- dependencies:
- chartjs-color "^2.1.0"
- moment "^2.10.2"
-
-chartjs-color-string@^0.6.0:
- version "0.6.0"
- resolved "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz"
- integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==
- dependencies:
- color-name "^1.0.0"
-
-chartjs-color@^2.1.0:
- version "2.4.1"
- resolved "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz"
- integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==
- dependencies:
- chartjs-color-string "^0.6.0"
- color-convert "^1.9.3"
-
"chokidar@>=3.0.0 <4.0.0":
version "3.5.3"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz"
@@ -1356,7 +1333,7 @@ clone-deep@^4.0.1:
kind-of "^6.0.2"
shallow-clone "^3.0.0"
-color-convert@^1.9.0, color-convert@^1.9.3:
+color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@@ -1368,11 +1345,6 @@ color-name@1.1.3:
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
-color-name@^1.0.0:
- version "1.1.4"
- resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
- integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
-
colord@^2.9.1:
version "2.9.2"
resolved "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz"
@@ -2106,7 +2078,7 @@ moment-timezone@^0.5.31:
dependencies:
moment ">= 2.9.0"
-"moment@>= 2.9.0", moment@^2.10.2, moment@^2.29.2:
+"moment@>= 2.9.0", moment@^2.29.2:
version "2.29.2"
resolved "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz"
integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==