Replace chart-js with backend rendering

Signed-off-by: Michael Weimann <mail@michael-weimann.eu>
This commit is contained in:
Michael Weimann 2022-06-07 22:59:49 +02:00 committed by Igor Scheller
parent 0b4782fcab
commit 1277f8f96f
15 changed files with 512 additions and 85 deletions

View File

@ -1,46 +0,0 @@
<?php
/**
* Renders a bargraph
*
* @param string $dom_id
* @param string $key key name of the x-axis
* @param array $row_names key names for the data rows
* @param array $colors colors for the data rows
* @param array $data the data
* @return string
*/
function bargraph($dom_id, $key, $row_names, $colors, $data)
{
$labels = [];
foreach ($data as $dataset) {
$labels[] = $dataset[$key];
}
$datasets = [];
foreach ($row_names as $row_key => $name) {
$values = [];
foreach ($data as $dataset) {
$values[] = $dataset[$row_key];
}
$datasets[] = [
'label' => $name,
'backgroundColor' => $colors[$row_key],
'data' => $values
];
}
return '<canvas id="' . $dom_id . '" style="width: 100%; height: 300px;"></canvas>
<script type="text/javascript">
$(function(){
var ctx = $(\'#' . $dom_id . '\').get(0).getContext(\'2d\');
var chart = new Chart(ctx, ' . json_encode([
'type' => 'bar',
'data' => [
'labels' => $labels,
'datasets' => $datasets
]
]) . ');
});
</script>';
}

View File

@ -55,7 +55,6 @@ $includeFiles = [
__DIR__ . '/../includes/controller/user_driver_licenses_controller.php', __DIR__ . '/../includes/controller/user_driver_licenses_controller.php',
__DIR__ . '/../includes/controller/user_worklog_controller.php', __DIR__ . '/../includes/controller/user_worklog_controller.php',
__DIR__ . '/../includes/helper/graph_helper.php',
__DIR__ . '/../includes/helper/legacy_helper.php', __DIR__ . '/../includes/helper/legacy_helper.php',
__DIR__ . '/../includes/helper/message_helper.php', __DIR__ . '/../includes/helper/message_helper.php',
__DIR__ . '/../includes/helper/email_helper.php', __DIR__ . '/../includes/helper/email_helper.php',

View File

@ -1,5 +1,6 @@
<?php <?php
use Engelsystem\Helpers\BarChart;
use Engelsystem\Models\User\User; use Engelsystem\Models\User\User;
/** /**
@ -193,8 +194,8 @@ function admin_arrive()
], $users_matched), ], $users_matched),
div('row', [ div('row', [
div('col-md-4', [ div('col-md-4', [
heading(__('Planned arrival statistics'), 2), heading(__('Planned arrival statistics'), 3),
bargraph('planned_arrives', 'day', [ BarChart::render([
'count' => __('arrived'), 'count' => __('arrived'),
'sum' => __('arrived sum') 'sum' => __('arrived sum')
], [ ], [
@ -208,8 +209,8 @@ function admin_arrive()
], $planned_arrival_at_day) ], $planned_arrival_at_day)
]), ]),
div('col-md-4', [ div('col-md-4', [
heading(__('Arrival statistics'), 2), heading(__('Arrival statistics'), 3),
bargraph('arrives', 'day', [ BarChart::render([
'count' => __('arrived'), 'count' => __('arrived'),
'sum' => __('arrived sum') 'sum' => __('arrived sum')
], [ ], [
@ -223,8 +224,8 @@ function admin_arrive()
], $arrival_at_day) ], $arrival_at_day)
]), ]),
div('col-md-4', [ div('col-md-4', [
heading(__('Planned departure statistics'), 2), heading(__('Planned departure statistics'), 3),
bargraph('planned_departures', 'day', [ BarChart::render([
'count' => __('arrived'), 'count' => __('arrived'),
'sum' => __('arrived sum') 'sum' => __('arrived sum')
], [ ], [

View File

@ -13,7 +13,6 @@
"dependencies": { "dependencies": {
"@popperjs/core": "^2.9.2", "@popperjs/core": "^2.9.2",
"bootstrap": "^5.1.0", "bootstrap": "^5.1.0",
"chart.js": "^2.9.3",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"jquery": "^3.5.1", "jquery": "^3.5.1",
"jquery-ui": "^1.13.0", "jquery-ui": "^1.13.0",

View File

@ -4,7 +4,6 @@ require('jquery-ui');
window.bootstrap = require('bootstrap'); window.bootstrap = require('bootstrap');
window.moment = require('moment'); window.moment = require('moment');
require('moment/locale/de'); require('moment/locale/de');
require('chart.js');
require('./forms'); require('./forms');
require('./sticky-headers'); require('./sticky-headers');
require('./moment-countdown'); require('./moment-countdown');

View File

@ -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;
}
}
}

View File

@ -54,6 +54,7 @@ $form-label-font-weight: $font-weight-bold;
@import "~select2/src/scss/core"; @import "~select2/src/scss/core";
@import "~select2-bootstrap-5-theme/src/include-all"; @import "~select2-bootstrap-5-theme/src/include-all";
@import "error"; @import "error";
@import "barchart";
$navbar-height: 3.125rem; $navbar-height: 3.125rem;

View File

@ -0,0 +1,33 @@
<div class="barchart {{ barChartClass }}">
<div class="barchart-graph-container">
{% for yLabel in yLabels %}
<label class="barchart-ylabel" style="bottom: {{ yLabel.bottom }};">
{{ yLabel.label }}
</label>
<hr class="barchart-ygraph" style="bottom: {{ yLabel.bottom }};" />
{% endfor %}
{% for group in groups %}
<div class="barchart-group">
{% for bar in group.bars %}
<div
class="barchart-bar"
title="bar.title"
style="height: {{ bar.height }}; background-color: {{ bar.bg }};"
></div>
{% endfor %}
<div class="barchart-xlabel">{{ group.label }}</div>
</div>
{% endfor %}
</div>
</div>
<div class="barchart-legend">
{% for key, color in colors %}
<div
class="barchart-legend-item"
style="border-left-color: {{ color }};"
>
{{ rowLabels[key] }}
</div>
{% endfor %}
</div>

View File

@ -374,6 +374,9 @@
</p> </p>
</div> </div>
</div> </div>
<h3>Bar Chart</h3>
{{ bar_chart | raw }}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -3,6 +3,7 @@
namespace Engelsystem\Controllers; namespace Engelsystem\Controllers;
use Engelsystem\Config\Config; use Engelsystem\Config\Config;
use Engelsystem\Helpers\BarChart;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\User\PersonalData; use Engelsystem\Models\User\PersonalData;
use Engelsystem\Models\User\State; use Engelsystem\Models\User\State;
@ -58,6 +59,7 @@ class DesignController extends BaseController
'demo_user' => $demoUser, 'demo_user' => $demoUser,
'demo_user_2' => $demoUser2, 'demo_user_2' => $demoUser2,
'themes' => $themes, 'themes' => $themes,
'bar_chart' => BarChart::render(...BarChart::generateChartDemoData(23)),
]; ];
return $this->response->withView( return $this->response->withView(

167
src/Helpers/BarChart.php Normal file
View File

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Helpers;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
class BarChart
{
/**
* Renders a bar chart using the "components/barchart" view.
*
* @param array<string> $rowLabels Map row key => row label
* @param array<string, string> $colors Map row key => color
* @param array<mixed, 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<array{label: string, bottom: string}>
*/
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,
];
}
}

View File

@ -2,6 +2,7 @@
namespace Engelsystem\Test\Unit\Controllers; namespace Engelsystem\Test\Unit\Controllers;
use Engelsystem\Application;
use Engelsystem\Config\Config; use Engelsystem\Config\Config;
use Engelsystem\Controllers\DesignController; use Engelsystem\Controllers\DesignController;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
@ -10,6 +11,14 @@ use PHPUnit\Framework\MockObject\MockObject;
class DesignControllerTest extends TestCase 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::__construct
* @covers \Engelsystem\Controllers\DesignController::index * @covers \Engelsystem\Controllers\DesignController::index

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Test\Unit\Helpers;
use Engelsystem\Application;
use Engelsystem\Helpers\BarChart;
use Engelsystem\Renderer\Renderer;
use Engelsystem\Test\Unit\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
class BarChartTest extends TestCase
{
private const ROW_LABELS = [
'a' => '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));
}
}

View File

@ -3,6 +3,7 @@
namespace Engelsystem\Test\Unit; namespace Engelsystem\Test\Unit;
use Engelsystem\Application; use Engelsystem\Application;
use Engelsystem\Renderer\Renderer;
use Faker\Factory as FakerFactory; use Faker\Factory as FakerFactory;
use Faker\Generator; use Faker\Generator;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
@ -56,4 +57,35 @@ abstract class TestCase extends PHPUnitTestCase
$faker->addProvider(new FakerProvider($faker)); $faker->addProvider(new FakerProvider($faker));
$this->app->instance(Generator::class, $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;
}
} }

View File

@ -1304,29 +1304,6 @@ chalk@^2.0.0:
escape-string-regexp "^1.0.5" escape-string-regexp "^1.0.5"
supports-color "^5.3.0" 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": "chokidar@>=3.0.0 <4.0.0":
version "3.5.3" version "3.5.3"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz" 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" kind-of "^6.0.2"
shallow-clone "^3.0.0" shallow-clone "^3.0.0"
color-convert@^1.9.0, color-convert@^1.9.3: color-convert@^1.9.0:
version "1.9.3" version "1.9.3"
resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz" resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 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" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= 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: colord@^2.9.1:
version "2.9.2" version "2.9.2"
resolved "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz" resolved "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz"
@ -2106,7 +2078,7 @@ moment-timezone@^0.5.31:
dependencies: dependencies:
moment ">= 2.9.0" 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" version "2.29.2"
resolved "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz" resolved "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz"
integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==