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==