From 688ff427522843c8a914be928bb384f85b774f11 Mon Sep 17 00:00:00 2001 From: matze Date: Tue, 10 Dec 2024 23:53:23 +0100 Subject: [PATCH] added simple math and charts modules --- composer.json | 4 +- composer.lock | 150 +- vendor/andileco/eval-math/.gitignore | 7 + vendor/andileco/eval-math/.travis.yml | 21 + vendor/andileco/eval-math/LICENSE | 25 + vendor/andileco/eval-math/README.md | 78 + vendor/andileco/eval-math/composer.json | 26 + vendor/andileco/eval-math/phpunit.xml.dist | 16 + vendor/andileco/eval-math/src/EvalMath.php | 511 +++++ .../Exception/AbstractEvalMathException.php | 21 + .../BuiltInFunctionRedefinitionException.php | 14 + .../Exception/ConstantAssignmentException.php | 14 + .../src/Exception/DivisionByZeroException.php | 14 + .../src/Exception/EvalMathException.php | 14 + .../src/Exception/ExpectingTokenException.php | 14 + .../Exception/IllegalCharacterException.php | 14 + .../src/Exception/InternalErrorException.php | 14 + .../InvalidArgumentCountException.php | 14 + .../OperatorLacksOperandException.php | 14 + .../Exception/OperatorRequiredException.php | 14 + .../Exception/UndefinedVariableException.php | 14 + ...dVariableInFunctionDefinitionException.php | 14 + .../Exception/UnexpectedOperatorException.php | 14 + .../Exception/UnexpectedTokenException.php | 14 + .../eval-math/src/Methods/AbstractMethod.php | 34 + .../eval-math/src/Methods/Conditional.php | 20 + .../eval-math/src/Methods/Maximum.php | 19 + .../eval-math/src/Methods/Minimum.php | 19 + .../andileco/eval-math/src/Methods/Round.php | 18 + .../eval-math/src/MethodsRegistry.php | 66 + vendor/andileco/eval-math/src/Stack.php | 45 + .../tests/EvalMathExceptionsTest.php | 216 +++ .../andileco/eval-math/tests/EvalMathTest.php | 140 ++ .../eval-math/tests/MethodRegistryTest.php | 63 + .../andileco/eval-math/tests/MethodsTest.php | 69 + .../eval-math/tests/UserFunctionsTest.php | 24 + .../eval-math/tests/UserVariablesTest.php | 25 + vendor/composer/autoload_psr4.php | 1 + vendor/composer/autoload_static.php | 5 + vendor/composer/installed.json | 157 ++ vendor/composer/installed.php | 39 +- web/modules/charts/.gitlab-ci.yml | 97 + web/modules/charts/LICENSE.txt | 339 ++++ web/modules/charts/README.md | 242 +++ web/modules/charts/charts.api.php | 174 ++ web/modules/charts/charts.charts_types.yml | 50 + .../charts/charts.config_translation.yml | 5 + web/modules/charts/charts.info.yml | 11 + web/modules/charts/charts.install | 15 + web/modules/charts/charts.libraries.yml | 7 + web/modules/charts/charts.links.menu.yml | 5 + web/modules/charts/charts.links.task.yml | 16 + web/modules/charts/charts.module | 230 +++ web/modules/charts/charts.permissions.yml | 4 + web/modules/charts/charts.post_update.php | 221 +++ web/modules/charts/charts.routing.yml | 15 + web/modules/charts/charts.services.yml | 21 + web/modules/charts/charts.settings.yml | 0 .../charts/config/install/charts.settings.yml | 73 + .../schema/charts.data_types.schema.yml | 216 +++ .../config/schema/charts.settings.schema.yml | 24 + .../config/schema/charts.views.schema.yml | 47 + web/modules/charts/js/charts.es6.js | 46 + web/modules/charts/js/charts.js | 45 + .../modules/charts_api_example/README.md | 15 + .../charts_api_example.info.yml | 12 + .../charts_api_example.libraries.yml | 6 + .../charts_api_example.links.menu.yml | 5 + .../charts_api_example.module | 43 + .../charts_api_example.routing.yml | 7 + .../modules/charts_api_example/composer.json | 16 + .../fixtures/charts_api_example_file.csv | 463 +++++ .../js/charts_api_example_js_override.js | 24 + .../src/Controller/ChartsApiExample.php | 400 ++++ .../charts/modules/charts_billboard/README.md | 117 ++ .../charts_billboard.info.yml | 12 + .../charts_billboard/charts_billboard.install | 95 + .../charts_billboard.libraries.yml | 36 + .../charts_billboard/charts_billboard.module | 17 + .../modules/charts_billboard/composer.json | 49 + .../charts_billboard/js/charts_billboard.js | 26 + .../src/Plugin/chart/Library/Billboard.php | 454 +++++ .../charts/modules/charts_blocks/README.md | 9 + .../charts_blocks/charts_blocks.info.yml | 12 + .../charts_blocks/charts_blocks.module | 24 + .../src/Plugin/Block/ChartsBlock.php | 114 ++ .../charts/modules/charts_c3/README.md | 137 ++ .../modules/charts_c3/charts_c3.info.yml | 12 + .../modules/charts_c3/charts_c3.install | 95 + .../modules/charts_c3/charts_c3.libraries.yml | 36 + .../charts/modules/charts_c3/charts_c3.module | 17 + .../charts/modules/charts_c3/composer.json | 49 + .../charts/modules/charts_c3/js/charts_c3.js | 21 + .../charts_c3/src/Plugin/chart/Library/C3.php | 454 +++++ .../charts/modules/charts_chartjs/README.md | 158 ++ .../charts_chartjs.charts_types.yml | 5 + .../charts_chartjs/charts_chartjs.info.yml | 12 + .../charts_chartjs/charts_chartjs.install | 95 + .../charts_chartjs.libraries.yml | 26 + .../charts_chartjs/charts_chartjs.module | 32 + .../modules/charts_chartjs/composer.json | 38 + .../config/schema/charts_chartjs.schema.yml | 20 + .../charts_chartjs/js/charts_chartjs.js | 51 + .../src/Plugin/chart/Library/Chartjs.php | 659 +++++++ .../tests/src/Kernel/DimensionsTest.php | 43 + .../tests/src/Kernel/RawOptionsTest.php | 43 + .../charts/modules/charts_google/README.md | 42 + .../charts_google.charts_types.yml | 5 + .../charts_google/charts_google.info.yml | 12 + .../charts_google/charts_google.install | 71 + .../charts_google/charts_google.libraries.yml | 22 + .../charts_google/charts_google.module | 17 + .../modules/charts_google/composer.json | 33 + .../config/schema/charts_google.schema.yml | 6 + .../modules/charts_google/js/charts_google.js | 187 ++ .../src/Plugin/chart/Library/Google.php | 600 ++++++ .../modules/charts_highcharts/README.md | 130 ++ .../charts_highcharts.info.yml | 12 + .../charts_highcharts.install | 95 + .../charts_highcharts.libraries.yml | 73 + .../charts_highcharts.module | 86 + .../charts_highcharts.post_update.php | 33 + .../modules/charts_highcharts/composer.json | 128 ++ .../schema/charts_highcharts.schema.yml | 149 ++ .../charts_highcharts/js/charts_highcharts.js | 40 + .../charts_highcharts/js/color_changer.js | 42 + .../modules/charts_highcharts/js/textures.js | 69 + .../src/Form/ColorChanger.php | 112 ++ .../src/Plugin/chart/Library/Highcharts.php | 1079 +++++++++++ .../tests/src/Kernel/RawOptionsTest.php | 62 + web/modules/charts/src/Annotation/Chart.php | 35 + web/modules/charts/src/ChartManager.php | 34 + .../charts/src/ChartViewsFieldInterface.php | 18 + web/modules/charts/src/ColorHelperTrait.php | 20 + web/modules/charts/src/ConfigUpdater.php | 346 ++++ .../src/DependenciesCalculatorTrait.php | 87 + .../charts/src/Element/BaseSettings.php | 1644 +++++++++++++++++ web/modules/charts/src/Element/Chart.php | 449 +++++ .../charts/src/Element/ChartAxisBase.php | 49 + web/modules/charts/src/Element/ChartData.php | 44 + .../src/Element/ChartDataCollectorTable.php | 909 +++++++++ .../charts/src/Element/ChartDataItem.php | 26 + web/modules/charts/src/Element/ChartXaxis.php | 12 + web/modules/charts/src/Element/ChartYaxis.php | 12 + .../src/Element/ElementFormStateTrait.php | 45 + web/modules/charts/src/Event/ChartsEvents.php | 21 + .../charts/src/Event/TypesInfoEvent.php | 82 + .../ConfigImportSubscriber.php | 87 + .../src/Form/ChartsConfigAdvancedForm.php | 72 + .../charts/src/Form/ChartsConfigForm.php | 203 ++ .../src/Plugin/DataType/ChartConfigData.php | 25 + .../ChartConfigItemDefaultFormatter.php | 89 + .../Field/FieldType/ChartConfigItem.php | 94 + .../ChartConfigItemDefaultWidget.php | 151 ++ .../src/Plugin/chart/Library/ChartBase.php | 152 ++ .../Plugin/chart/Library/ChartInterface.php | 69 + .../charts/src/Plugin/chart/Type/Type.php | 47 + .../src/Plugin/chart/Type/TypeInterface.php | 50 + .../display/ChartsPluginDisplayChart.php | 153 ++ .../src/Plugin/views/field/BubbleField.php | 218 +++ .../Plugin/views/field/ExposedChartType.php | 153 ++ .../src/Plugin/views/field/ScatterField.php | 201 ++ .../views/style/ChartsPluginStyleChart.php | 929 ++++++++++ .../charts/src/PluginsUninstallValidator.php | 46 + .../ProxyClass/PluginsUninstallValidator.php | 88 + web/modules/charts/src/TypeManager.php | 104 ++ web/modules/charts/src/Util/Util.php | 151 ++ .../charts/templates/charts-chart.html.twig | 4 + .../modules/charts_test/charts_test.info.yml | 12 + .../charts_test/charts_test.routing.yml | 8 + .../config/schema/charts_test.schema.yml | 6 + .../src/Form/DataCollectorTableTestForm.php | 52 + .../chart/Library/ChartsTestLibrary.php | 166 ++ .../test_views/views.view.test_charts.yml | 144 ++ .../tests/resources/csv/first_column.csv | 7 + .../charts/tests/resources/csv/first_row.csv | 2 + .../src/Functional/Plugin/StyleChartsTest.php | 59 + .../ConfigOverrideTest.php | 48 + .../DataCollectorTableTest.php | 289 +++ .../tests/src/Kernel/ChartElementTest.php | 53 + .../src/Kernel/ChartLibraryConfigTest.php | 55 + .../tests/src/Kernel/ChartTypeSupportTest.php | 83 + .../tests/src/Kernel/ChartsKernelTestBase.php | 110 ++ .../tests/src/Kernel/DimensionsTest.php | 72 + .../tests/src/Traits/ConfigUpdateTrait.php | 27 + .../views_simple_math_field/.gitlab-ci.yml | 99 + .../views_simple_math_field/LICENSE.txt | 339 ++++ web/modules/views_simple_math_field/README.md | 66 + .../views_simple_math_field/composer.json | 16 + .../views_simple_math_field.views.schema.yml | 27 + .../views_simple_math_field/ludwig.json | 8 + .../Plugin/views/field/SimpleMathField.php | 514 ++++++ .../views/sort/ViewsSimpleMathFieldSort.php | 88 + .../views_simple_math_field.info.yml | 12 + .../views_simple_math_field.views.inc | 30 + web/sites/default/files/db.sqlite | Bin 9441280 -> 9498624 bytes 196 files changed, 20285 insertions(+), 8 deletions(-) create mode 100644 vendor/andileco/eval-math/.gitignore create mode 100644 vendor/andileco/eval-math/.travis.yml create mode 100644 vendor/andileco/eval-math/LICENSE create mode 100644 vendor/andileco/eval-math/README.md create mode 100644 vendor/andileco/eval-math/composer.json create mode 100644 vendor/andileco/eval-math/phpunit.xml.dist create mode 100644 vendor/andileco/eval-math/src/EvalMath.php create mode 100644 vendor/andileco/eval-math/src/Exception/AbstractEvalMathException.php create mode 100644 vendor/andileco/eval-math/src/Exception/BuiltInFunctionRedefinitionException.php create mode 100644 vendor/andileco/eval-math/src/Exception/ConstantAssignmentException.php create mode 100644 vendor/andileco/eval-math/src/Exception/DivisionByZeroException.php create mode 100644 vendor/andileco/eval-math/src/Exception/EvalMathException.php create mode 100644 vendor/andileco/eval-math/src/Exception/ExpectingTokenException.php create mode 100644 vendor/andileco/eval-math/src/Exception/IllegalCharacterException.php create mode 100644 vendor/andileco/eval-math/src/Exception/InternalErrorException.php create mode 100644 vendor/andileco/eval-math/src/Exception/InvalidArgumentCountException.php create mode 100644 vendor/andileco/eval-math/src/Exception/OperatorLacksOperandException.php create mode 100644 vendor/andileco/eval-math/src/Exception/OperatorRequiredException.php create mode 100644 vendor/andileco/eval-math/src/Exception/UndefinedVariableException.php create mode 100644 vendor/andileco/eval-math/src/Exception/UndefinedVariableInFunctionDefinitionException.php create mode 100644 vendor/andileco/eval-math/src/Exception/UnexpectedOperatorException.php create mode 100644 vendor/andileco/eval-math/src/Exception/UnexpectedTokenException.php create mode 100644 vendor/andileco/eval-math/src/Methods/AbstractMethod.php create mode 100644 vendor/andileco/eval-math/src/Methods/Conditional.php create mode 100644 vendor/andileco/eval-math/src/Methods/Maximum.php create mode 100644 vendor/andileco/eval-math/src/Methods/Minimum.php create mode 100644 vendor/andileco/eval-math/src/Methods/Round.php create mode 100644 vendor/andileco/eval-math/src/MethodsRegistry.php create mode 100644 vendor/andileco/eval-math/src/Stack.php create mode 100644 vendor/andileco/eval-math/tests/EvalMathExceptionsTest.php create mode 100644 vendor/andileco/eval-math/tests/EvalMathTest.php create mode 100644 vendor/andileco/eval-math/tests/MethodRegistryTest.php create mode 100644 vendor/andileco/eval-math/tests/MethodsTest.php create mode 100644 vendor/andileco/eval-math/tests/UserFunctionsTest.php create mode 100644 vendor/andileco/eval-math/tests/UserVariablesTest.php create mode 100644 web/modules/charts/.gitlab-ci.yml create mode 100644 web/modules/charts/LICENSE.txt create mode 100755 web/modules/charts/README.md create mode 100644 web/modules/charts/charts.api.php create mode 100755 web/modules/charts/charts.charts_types.yml create mode 100644 web/modules/charts/charts.config_translation.yml create mode 100644 web/modules/charts/charts.info.yml create mode 100755 web/modules/charts/charts.install create mode 100644 web/modules/charts/charts.libraries.yml create mode 100755 web/modules/charts/charts.links.menu.yml create mode 100755 web/modules/charts/charts.links.task.yml create mode 100755 web/modules/charts/charts.module create mode 100755 web/modules/charts/charts.permissions.yml create mode 100644 web/modules/charts/charts.post_update.php create mode 100755 web/modules/charts/charts.routing.yml create mode 100755 web/modules/charts/charts.services.yml create mode 100755 web/modules/charts/charts.settings.yml create mode 100644 web/modules/charts/config/install/charts.settings.yml create mode 100644 web/modules/charts/config/schema/charts.data_types.schema.yml create mode 100755 web/modules/charts/config/schema/charts.settings.schema.yml create mode 100755 web/modules/charts/config/schema/charts.views.schema.yml create mode 100644 web/modules/charts/js/charts.es6.js create mode 100644 web/modules/charts/js/charts.js create mode 100755 web/modules/charts/modules/charts_api_example/README.md create mode 100644 web/modules/charts/modules/charts_api_example/charts_api_example.info.yml create mode 100644 web/modules/charts/modules/charts_api_example/charts_api_example.libraries.yml create mode 100755 web/modules/charts/modules/charts_api_example/charts_api_example.links.menu.yml create mode 100755 web/modules/charts/modules/charts_api_example/charts_api_example.module create mode 100755 web/modules/charts/modules/charts_api_example/charts_api_example.routing.yml create mode 100755 web/modules/charts/modules/charts_api_example/composer.json create mode 100644 web/modules/charts/modules/charts_api_example/fixtures/charts_api_example_file.csv create mode 100644 web/modules/charts/modules/charts_api_example/js/charts_api_example_js_override.js create mode 100755 web/modules/charts/modules/charts_api_example/src/Controller/ChartsApiExample.php create mode 100755 web/modules/charts/modules/charts_billboard/README.md create mode 100644 web/modules/charts/modules/charts_billboard/charts_billboard.info.yml create mode 100644 web/modules/charts/modules/charts_billboard/charts_billboard.install create mode 100755 web/modules/charts/modules/charts_billboard/charts_billboard.libraries.yml create mode 100644 web/modules/charts/modules/charts_billboard/charts_billboard.module create mode 100755 web/modules/charts/modules/charts_billboard/composer.json create mode 100755 web/modules/charts/modules/charts_billboard/js/charts_billboard.js create mode 100755 web/modules/charts/modules/charts_billboard/src/Plugin/chart/Library/Billboard.php create mode 100644 web/modules/charts/modules/charts_blocks/README.md create mode 100644 web/modules/charts/modules/charts_blocks/charts_blocks.info.yml create mode 100755 web/modules/charts/modules/charts_blocks/charts_blocks.module create mode 100755 web/modules/charts/modules/charts_blocks/src/Plugin/Block/ChartsBlock.php create mode 100755 web/modules/charts/modules/charts_c3/README.md create mode 100644 web/modules/charts/modules/charts_c3/charts_c3.info.yml create mode 100644 web/modules/charts/modules/charts_c3/charts_c3.install create mode 100755 web/modules/charts/modules/charts_c3/charts_c3.libraries.yml create mode 100644 web/modules/charts/modules/charts_c3/charts_c3.module create mode 100755 web/modules/charts/modules/charts_c3/composer.json create mode 100755 web/modules/charts/modules/charts_c3/js/charts_c3.js create mode 100755 web/modules/charts/modules/charts_c3/src/Plugin/chart/Library/C3.php create mode 100755 web/modules/charts/modules/charts_chartjs/README.md create mode 100644 web/modules/charts/modules/charts_chartjs/charts_chartjs.charts_types.yml create mode 100644 web/modules/charts/modules/charts_chartjs/charts_chartjs.info.yml create mode 100644 web/modules/charts/modules/charts_chartjs/charts_chartjs.install create mode 100755 web/modules/charts/modules/charts_chartjs/charts_chartjs.libraries.yml create mode 100644 web/modules/charts/modules/charts_chartjs/charts_chartjs.module create mode 100755 web/modules/charts/modules/charts_chartjs/composer.json create mode 100644 web/modules/charts/modules/charts_chartjs/config/schema/charts_chartjs.schema.yml create mode 100755 web/modules/charts/modules/charts_chartjs/js/charts_chartjs.js create mode 100755 web/modules/charts/modules/charts_chartjs/src/Plugin/chart/Library/Chartjs.php create mode 100644 web/modules/charts/modules/charts_chartjs/tests/src/Kernel/DimensionsTest.php create mode 100644 web/modules/charts/modules/charts_chartjs/tests/src/Kernel/RawOptionsTest.php create mode 100755 web/modules/charts/modules/charts_google/README.md create mode 100755 web/modules/charts/modules/charts_google/charts_google.charts_types.yml create mode 100644 web/modules/charts/modules/charts_google/charts_google.info.yml create mode 100644 web/modules/charts/modules/charts_google/charts_google.install create mode 100755 web/modules/charts/modules/charts_google/charts_google.libraries.yml create mode 100644 web/modules/charts/modules/charts_google/charts_google.module create mode 100755 web/modules/charts/modules/charts_google/composer.json create mode 100644 web/modules/charts/modules/charts_google/config/schema/charts_google.schema.yml create mode 100755 web/modules/charts/modules/charts_google/js/charts_google.js create mode 100755 web/modules/charts/modules/charts_google/src/Plugin/chart/Library/Google.php create mode 100755 web/modules/charts/modules/charts_highcharts/README.md create mode 100644 web/modules/charts/modules/charts_highcharts/charts_highcharts.info.yml create mode 100755 web/modules/charts/modules/charts_highcharts/charts_highcharts.install create mode 100755 web/modules/charts/modules/charts_highcharts/charts_highcharts.libraries.yml create mode 100644 web/modules/charts/modules/charts_highcharts/charts_highcharts.module create mode 100644 web/modules/charts/modules/charts_highcharts/charts_highcharts.post_update.php create mode 100755 web/modules/charts/modules/charts_highcharts/composer.json create mode 100644 web/modules/charts/modules/charts_highcharts/config/schema/charts_highcharts.schema.yml create mode 100755 web/modules/charts/modules/charts_highcharts/js/charts_highcharts.js create mode 100755 web/modules/charts/modules/charts_highcharts/js/color_changer.js create mode 100755 web/modules/charts/modules/charts_highcharts/js/textures.js create mode 100644 web/modules/charts/modules/charts_highcharts/src/Form/ColorChanger.php create mode 100755 web/modules/charts/modules/charts_highcharts/src/Plugin/chart/Library/Highcharts.php create mode 100644 web/modules/charts/modules/charts_highcharts/tests/src/Kernel/RawOptionsTest.php create mode 100755 web/modules/charts/src/Annotation/Chart.php create mode 100755 web/modules/charts/src/ChartManager.php create mode 100644 web/modules/charts/src/ChartViewsFieldInterface.php create mode 100644 web/modules/charts/src/ColorHelperTrait.php create mode 100644 web/modules/charts/src/ConfigUpdater.php create mode 100644 web/modules/charts/src/DependenciesCalculatorTrait.php create mode 100755 web/modules/charts/src/Element/BaseSettings.php create mode 100755 web/modules/charts/src/Element/Chart.php create mode 100755 web/modules/charts/src/Element/ChartAxisBase.php create mode 100755 web/modules/charts/src/Element/ChartData.php create mode 100755 web/modules/charts/src/Element/ChartDataCollectorTable.php create mode 100755 web/modules/charts/src/Element/ChartDataItem.php create mode 100755 web/modules/charts/src/Element/ChartXaxis.php create mode 100755 web/modules/charts/src/Element/ChartYaxis.php create mode 100644 web/modules/charts/src/Element/ElementFormStateTrait.php create mode 100755 web/modules/charts/src/Event/ChartsEvents.php create mode 100755 web/modules/charts/src/Event/TypesInfoEvent.php create mode 100644 web/modules/charts/src/EventSubscriber/ConfigImportSubscriber.php create mode 100644 web/modules/charts/src/Form/ChartsConfigAdvancedForm.php create mode 100755 web/modules/charts/src/Form/ChartsConfigForm.php create mode 100755 web/modules/charts/src/Plugin/DataType/ChartConfigData.php create mode 100755 web/modules/charts/src/Plugin/Field/FieldFormatter/ChartConfigItemDefaultFormatter.php create mode 100755 web/modules/charts/src/Plugin/Field/FieldType/ChartConfigItem.php create mode 100755 web/modules/charts/src/Plugin/Field/FieldWidget/ChartConfigItemDefaultWidget.php create mode 100755 web/modules/charts/src/Plugin/chart/Library/ChartBase.php create mode 100755 web/modules/charts/src/Plugin/chart/Library/ChartInterface.php create mode 100755 web/modules/charts/src/Plugin/chart/Type/Type.php create mode 100755 web/modules/charts/src/Plugin/chart/Type/TypeInterface.php create mode 100755 web/modules/charts/src/Plugin/views/display/ChartsPluginDisplayChart.php create mode 100644 web/modules/charts/src/Plugin/views/field/BubbleField.php create mode 100755 web/modules/charts/src/Plugin/views/field/ExposedChartType.php create mode 100755 web/modules/charts/src/Plugin/views/field/ScatterField.php create mode 100755 web/modules/charts/src/Plugin/views/style/ChartsPluginStyleChart.php create mode 100644 web/modules/charts/src/PluginsUninstallValidator.php create mode 100644 web/modules/charts/src/ProxyClass/PluginsUninstallValidator.php create mode 100755 web/modules/charts/src/TypeManager.php create mode 100755 web/modules/charts/src/Util/Util.php create mode 100755 web/modules/charts/templates/charts-chart.html.twig create mode 100644 web/modules/charts/tests/modules/charts_test/charts_test.info.yml create mode 100755 web/modules/charts/tests/modules/charts_test/charts_test.routing.yml create mode 100644 web/modules/charts/tests/modules/charts_test/config/schema/charts_test.schema.yml create mode 100755 web/modules/charts/tests/modules/charts_test/src/Form/DataCollectorTableTestForm.php create mode 100755 web/modules/charts/tests/modules/charts_test/src/Plugin/chart/Library/ChartsTestLibrary.php create mode 100644 web/modules/charts/tests/modules/charts_test/test_views/views.view.test_charts.yml create mode 100755 web/modules/charts/tests/resources/csv/first_column.csv create mode 100755 web/modules/charts/tests/resources/csv/first_row.csv create mode 100644 web/modules/charts/tests/src/Functional/Plugin/StyleChartsTest.php create mode 100644 web/modules/charts/tests/src/FunctionalJavascript/ConfigOverrideTest.php create mode 100755 web/modules/charts/tests/src/FunctionalJavascript/DataCollectorTableTest.php create mode 100644 web/modules/charts/tests/src/Kernel/ChartElementTest.php create mode 100644 web/modules/charts/tests/src/Kernel/ChartLibraryConfigTest.php create mode 100644 web/modules/charts/tests/src/Kernel/ChartTypeSupportTest.php create mode 100644 web/modules/charts/tests/src/Kernel/ChartsKernelTestBase.php create mode 100644 web/modules/charts/tests/src/Kernel/DimensionsTest.php create mode 100644 web/modules/charts/tests/src/Traits/ConfigUpdateTrait.php create mode 100644 web/modules/views_simple_math_field/.gitlab-ci.yml create mode 100644 web/modules/views_simple_math_field/LICENSE.txt create mode 100644 web/modules/views_simple_math_field/README.md create mode 100644 web/modules/views_simple_math_field/composer.json create mode 100644 web/modules/views_simple_math_field/config/schema/views_simple_math_field.views.schema.yml create mode 100644 web/modules/views_simple_math_field/ludwig.json create mode 100644 web/modules/views_simple_math_field/src/Plugin/views/field/SimpleMathField.php create mode 100644 web/modules/views_simple_math_field/src/Plugin/views/sort/ViewsSimpleMathFieldSort.php create mode 100644 web/modules/views_simple_math_field/views_simple_math_field.info.yml create mode 100644 web/modules/views_simple_math_field/views_simple_math_field.views.inc diff --git a/composer.json b/composer.json index bdfdcff4..ac19af79 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,9 @@ "require": { "farmos/farmos": "^3.0", "oomphinc/composer-installers-extender": "^2.0", - "wikimedia/composer-merge-plugin": "^2.0" + "wikimedia/composer-merge-plugin": "^2.0", + "drupal/views_simple_math_field": "^3.0", + "drupal/charts": "^5.1" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/composer.lock b/composer.lock index 38d34383..c6f6b5b1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,54 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "103f9b8db31610ca1687c620aeec857d", + "content-hash": "b863e8929cb118de1242b4471666ad20", "packages": [ + { + "name": "andileco/eval-math", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/andileco/eval-math.git", + "reference": "52ff5728ad51961a630cc957328764cb02ab5c51" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andileco/eval-math/zipball/52ff5728ad51961a630cc957328764cb02ab5c51", + "reference": "52ff5728ad51961a630cc957328764cb02ab5c51", + "shasum": "" + }, + "require": { + "php": "^7 || ^8.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2", + "phpunit/phpunit": "^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Andileco\\Util\\EvalMath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "EvalMath", + "keywords": [ + "EvalMath" + ], + "support": { + "issues": "https://github.com/andileco/eval-math/issues", + "source": "https://github.com/andileco/eval-math/tree/3.0.1" + }, + "time": "2022-07-21T21:04:26+00:00" + }, { "name": "asm89/stack-cors", "version": "v2.2.0", @@ -1339,6 +1385,58 @@ "issues": "https://www.drupal.org/project/issues/admin_toolbar" } }, + { + "name": "drupal/charts", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/charts.git", + "reference": "5.1.1" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/charts-5.1.1.zip", + "reference": "5.1.1", + "shasum": "81529ec926462ddc62e9f9f78d47b036af566b6b" + }, + "require": { + "drupal/core": "^10.3 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "5.1.1", + "datestamp": "1733170631", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "andileco", + "homepage": "https://www.drupal.org/user/2054544" + }, + { + "name": "nikathone", + "homepage": "https://www.drupal.org/user/2421800" + }, + { + "name": "quicksketch", + "homepage": "https://www.drupal.org/user/35821" + } + ], + "description": "A charting API for Drupal that provides chart elements and integration with Views.", + "homepage": "https://www.drupal.org/project/charts", + "support": { + "source": "https://git.drupalcode.org/project/charts" + } + }, { "name": "drupal/config_update", "version": "2.0.0-alpha4", @@ -3415,6 +3513,56 @@ "irc": "irc://irc.freenode.org/drupal-contribute" } }, + { + "name": "drupal/views_simple_math_field", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/views_simple_math_field.git", + "reference": "3.0.2" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/views_simple_math_field-3.0.2.zip", + "reference": "3.0.2", + "shasum": "6620ee614bbaf68b9c26227e01c74299be47a6d8" + }, + "require": { + "andileco/eval-math": "^3.0.1", + "drupal/core": "^8.7|^9.0 || ^10 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "3.0.2", + "datestamp": "1719379402", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "andileco", + "homepage": "https://www.drupal.org/user/2054544" + }, + { + "name": "Beakerboy", + "homepage": "https://www.drupal.org/user/1700098" + } + ], + "description": "Math operations over selected fields", + "homepage": "https://www.drupal.org/project/views_simple_math_field", + "support": { + "source": "http://cgit.drupalcode.org/views_simple_math_field", + "issues": "https://www.drupal.org/project/issues/views_simple_math_field" + } + }, { "name": "drush/drush", "version": "12.5.3", diff --git a/vendor/andileco/eval-math/.gitignore b/vendor/andileco/eval-math/.gitignore new file mode 100644 index 00000000..a3c1b666 --- /dev/null +++ b/vendor/andileco/eval-math/.gitignore @@ -0,0 +1,7 @@ +/.idea +/vendor +/composer.lock +.project +.settings +.buildpath +/build \ No newline at end of file diff --git a/vendor/andileco/eval-math/.travis.yml b/vendor/andileco/eval-math/.travis.yml new file mode 100644 index 00000000..282e4f92 --- /dev/null +++ b/vendor/andileco/eval-math/.travis.yml @@ -0,0 +1,21 @@ +language: php + +before_script: + - composer self-update + - composer config platform.php $PHP_VERSION + - composer install --prefer-source --no-interaction --dev + +matrix: + fast_finish: true + include: + - php: '5.6' + env: PHP_VERSION=5.6 + - php: '7.0' + env: PHP_VERSION=7.0 + - php: '7.1' + env: PHP_VERSION=7.1 + - php: '7.2' + env: PHP_VERSION=7.2 + +after_success: + - php vendor/bin/php-coveralls -v diff --git a/vendor/andileco/eval-math/LICENSE b/vendor/andileco/eval-math/LICENSE new file mode 100644 index 00000000..c78e148b --- /dev/null +++ b/vendor/andileco/eval-math/LICENSE @@ -0,0 +1,25 @@ +LICENSE + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + 1 Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/andileco/eval-math/README.md b/vendor/andileco/eval-math/README.md new file mode 100644 index 00000000..3a174796 --- /dev/null +++ b/vendor/andileco/eval-math/README.md @@ -0,0 +1,78 @@ + +Composer/Packagist version of EvalMath by Miles Kaufman +Copyright (C) 2005 Miles Kaufmann +NAME +---- + EvalMath - safely evaluate math expressions + +DESCRIPTION +----------- + Use the EvalMath class when you want to evaluate mathematical expressions + from untrusted sources. You can define your own variables and functions, + which are stored in the object. Try it, it's fun! + +SYNOPSIS +-------- + `$m = new EvalMath;` + + `// basic evaluation:` + `$result = $m->evaluate('2+2');` + + `// supports: order of operation; parentheses; negation; built-in functions` + `$result = $m->evaluate('-8(5/2)^2*(1-sqrt(4))-8');` + + `// create your own variables` + `$m->evaluate('a = e^(ln(pi))');` + + `// or functions` + `$m->evaluate('f(x,y) = x^2 + y^2 - 2x*y + 1');` + + `// and then use them` + `$result = $m->evaluate('3*f(42,a)');` + + `// use methods (calc functions) + `$m->evaluate('1+max(2,3)') // => 4` + `$m->evaluate('if(1=2, 2+2, 5+5') // => 10 + +METHODS +------- + `$m->evaluate($expr)` + Evaluates the expression and returns the result. If an error occurs, + prints a warning and returns false. If $expr is a function assignment, + returns true on success. + + `$m->e($expr)` + A synonym for $m->evaluate(). + + `$m->vars()` + Returns an associative array of all user-defined variables and values. + + `$m->funcs()` + Returns an array of all user-defined functions. + +CALC METHODS (CALC FUNCTIONS) +----------------------------- + +- `max(n...,m)` returns one of given arguments with maximal value +- `min(n...,m)` returns one of given arguments with minimal value +- `if(expr, true_value, false_value)` (has a `iif` synonym) returns `true_value` of `false_value` depends of `expr` evaluation result +- `round(n,m)` returns rounded n value with m precision + +CREATE YOUR OWN CUSTOM CALC METHODS (CALC FUNCTIONS) +----------------------------------------------- + You can create custom classes, that implements custom calc methods. + +TODO +---- +- Improve documentation +- Ability to add custom operators +- More tests + +CREDITS AND COPYRIGHTS +---------------------- +This software integrates several libraries and patches by: + +- Original EvalMath library, (C) 2005 Miles Kaufmann +- Modifications for 'calc functions' by Moodle, +- Composer/Packagist version, (C) Daniel Bojdo, + \ No newline at end of file diff --git a/vendor/andileco/eval-math/composer.json b/vendor/andileco/eval-math/composer.json new file mode 100644 index 00000000..91be9806 --- /dev/null +++ b/vendor/andileco/eval-math/composer.json @@ -0,0 +1,26 @@ +{ + "name" : "andileco/eval-math", + "description" : "EvalMath", + "license": "BSD-3-Clause", + "type" : "library", + "keywords" : [ + "EvalMath" + ], + "require": { + "php": "^7 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^5", + "php-coveralls/php-coveralls": "^2" + }, + "autoload" : { + "psr-4" : { + "Andileco\\Util\\EvalMath\\" : "src/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + } +} diff --git a/vendor/andileco/eval-math/phpunit.xml.dist b/vendor/andileco/eval-math/phpunit.xml.dist new file mode 100644 index 00000000..61d665a1 --- /dev/null +++ b/vendor/andileco/eval-math/phpunit.xml.dist @@ -0,0 +1,16 @@ + + + + + ./tests + + + + + src/ + + + + + + \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/EvalMath.php b/vendor/andileco/eval-math/src/EvalMath.php new file mode 100644 index 00000000..8b481a1e --- /dev/null +++ b/vendor/andileco/eval-math/src/EvalMath.php @@ -0,0 +1,511 @@ +2.71,'pi'=>3.14]; // variables (and constants) + + /** + * @var array + */ + public $f = []; // user-defined functions + + /** + * @var array + */ + public $vb = ['e', 'pi']; // constants + + /** + * @var array + */ + public $fb = [ // built-in functions + 'sin','sinh','arcsin','asin','arcsinh','asinh', + 'cos','cosh','arccos','acos','arccosh','acosh', + 'tan','tanh','arctan','atan','arctanh','atanh', + 'sqrt','abs','ln','log' + ]; + + // Calc functions +// public $fc =['average'=>[-1], 'max'=>[-1], 'min'=>[-1], 'iif'=>[3]]; + /** @var MethodsRegistry */ + protected $fc; + + public function __construct() + { + // make the variables a little more accurate + $this->v['pi'] = pi(); + $this->v['e'] = exp(1); + $this->fc = (new MethodsRegistry) + ->set(new Conditional) + ->set((new Conditional)->setName('iif')) + ->set(new Maximum) + ->set(new Minimum) + ->set(new Round); + } + + /** + * @return MethodsRegistry + */ + public function getMethodsRegistry() + { + return $this->fc; + } + + /** + * @param string $expr + * @return mixed + * @throws BuiltInFunctionRedefinitionException + * @throws ConstantAssignmentException + * @throws DivisionByZeroException + * @throws ExpectingTokenException + * @throws IllegalCharacterException + * @throws InternalErrorException + * @throws InvalidArgumentCountException + * @throws OperatorLacksOperandException + * @throws OperatorRequiredException + * @throws UndefinedVariableException + * @throws UndefinedVariableInFunctionDefinitionException + * @throws UnexpectedOperatorException + * @throws UnexpectedTokenException + */ + public function e($expr) + { + return $this->evaluate($expr); + } + + /** + * @param string $expr + * @return mixed + * @throws BuiltInFunctionRedefinitionException + * @throws ConstantAssignmentException + * @throws DivisionByZeroException + * @throws ExpectingTokenException + * @throws IllegalCharacterException + * @throws InternalErrorException + * @throws InvalidArgumentCountException + * @throws OperatorLacksOperandException + * @throws OperatorRequiredException + * @throws UndefinedVariableException + * @throws UndefinedVariableInFunctionDefinitionException + * @throws UnexpectedOperatorException + * @throws UnexpectedTokenException + */ + public function evaluate($expr) + { + $this->last_error = null; + $expr = rtrim(trim($expr), ';'); // strip semicolons at the end + + //=============== + // is it a variable assignment? + if (preg_match('/^\s*([a-z]\w*)\s*=\s*(.+)$/', $expr, $matches)) { + if (in_array($matches[1], $this->vb)) { // make sure we're not assigning to a constant + throw new ConstantAssignmentException([':constant' => $matches[1]]); + } + if (($tmp = $this->pfx($this->nfx($matches[2]))) === false) return false; // get the result and make sure it's good + $this->v[$matches[1]] = $tmp; // if so, stick it in the variable array + return $this->v[$matches[1]]; // and return the resulting value + //=============== + // is it a function assignment? + } elseif (preg_match('/^\s*([a-z]\w*)\s*\(\s*([a-z]\w*(?:\s*,\s*[a-z]\w*)*)\s*\)\s*=\s*(.+)$/', $expr, $matches)) { + $fnn = $matches[1]; // get the function name + if (in_array($matches[1], $this->fb)) { // make sure it isn't built in + throw new BuiltInFunctionRedefinitionException([':function' =>$matches[1]]); + } + $args = explode(",", preg_replace("/\s+/", "", $matches[2])); // get the arguments + if (($stack = $this->nfx($matches[3])) === false) return false; // see if it can be converted to postfix + for ($i = 0; $iv)) { + $stack[$i] = $this->v[$token]; + } else { + throw new UndefinedVariableInFunctionDefinitionException([':token'=>$token]); + } + } + } + $this->f[$fnn] = ['args'=>$args, 'func'=>$stack]; + return true; + //=============== + } else { + return $this->pfx($this->nfx($expr)); // straight up evaluation, woo + } + } + + /** + * @return array + */ + public function vars() + { + $output = $this->v; + unset($output['pi'], $output['e']); + return $output; + } + + /** + * @return array + */ + public function funcs() + { + $output = []; + foreach ($this->f as $fnn=>$dat) + $output[] = $fnn . '(' . implode(',', $dat['args']) . ')'; + + return $output; + } + + //===================== HERE BE INTERNAL METHODS ====================\\ + + /** + * Convert infix to postfix notation + * + * @param $expr + * @return array|bool + * @throws ExpectingTokenException + * @throws IllegalCharacterException + * @throws InternalErrorException + * @throws InvalidArgumentCountException + * @throws OperatorLacksOperandException + * @throws OperatorRequiredException + * @throws UnexpectedOperatorException + * @throws UnexpectedTokenException + */ + public function nfx($expr) + { + + $index = 0; + $stack = new Stack(); + $output = []; // postfix form of expression, to be passed to pfx() + // $expr = trim(strtolower($expr)); + $expr = trim($expr); + + $ops = ['+', '-', '*', '/', '^', '_', '%', '>', '<', '<=', '>=', '==']; + $ops_r = ['+' => 0, '-' => 0, '*' => 0, '/' => 0, '^' => 1, '%' => 0]; // right-associative operator? + $ops_p = ['+' => 0, '-' => 0, '*' => 1, '/' => 1, '_' => 1, '^' => 2, '%' => 1, '>' => 3, '<' => 3, '<=' => 3, '>=' => 3, '==' => 3]; // operator precedence + + $expecting_op = false; // we use this in syntax-checking the expression + // and determining when a - is a negation + + if (preg_match('/[^\%\w\s+*^\/()\.,-<>=]/', $expr, $matches)) { // make sure the characters are all good + throw new IllegalCharacterException([':character'=>$matches[0]]); + } + + while(1) { // 1 Infinite Loop ;) + $op = substr($expr, $index, 2); + if (!in_array($op, $ops)) { + $op = substr($expr, $index, 1); + } + + // find out if we're currently at the beginning of a number/variable/function/parenthesis/operand +// $ex = preg_match('/^([A-Za-z]\w*\(?|\d+(?:\.\d*)?|\.\d+|\()/', substr($expr, $index), $match); + $ex = preg_match('/^('.static::NAME_PATTERN.'\(?|\d+(?:\.\d*)?(?:(e[+-]?)\d*)?|\.\d+|\()/', substr($expr, $index), $match); + //=============== + if ($op == '-' and !$expecting_op) { // is it a negation instead of a minus? + $stack->push('_'); // put a negation on the stack + $index++; + } elseif ($op == '_') { // we have to explicitly deny this, because it's legal on the stack + throw new IllegalCharacterException([':character'=>'_']); + //=============== + } elseif ((in_array($op, $ops) or $ex) and $expecting_op) { // are we putting an operator on the stack? + if ($ex) { // are we expecting an operator but have a number/variable/function/opening parethesis? + throw new OperatorRequiredException(); + /* + * Implicit multiplication currently is not supported + * + $op = '*'; + $index--; // it's an implicit multiplication + */ + } + // heart of the algorithm: + while($stack->count > 0 and ($o2 = $stack->last()) and in_array($o2, $ops) and ($ops_r[$op] ? $ops_p[$op] < $ops_p[$o2] : $ops_p[$op] <= $ops_p[$o2])) { + $output[] = $stack->pop(); // pop stuff off the stack into the output + } + // many thanks: http://en.wikipedia.org/wiki/Reverse_Polish_notation#The_algorithm_in_detail + $stack->push($op); // finally put OUR operator onto the stack + $index += strlen($op); + $expecting_op = false; + //=============== + } elseif ($op == ')' and $expecting_op) { // ready to close a parenthesis? + while (($o2 = $stack->pop()) != '(') { // pop off the stack back to the last ( + if (null === $o2) { + throw new UnexpectedTokenException(['token'=>')']); + } else { + $output[] = $o2; + } + } + if (preg_match("/^([A-Za-z]\w*)\($/", $stack->last(2) ?? '', $matches)) { // did we just close a function? + $fnn = $matches[1]; // get the function name + $arg_count = $stack->pop(); // see how many arguments there were (cleverly stored on the stack, thank you) + $fn = $stack->pop(); + $output[] = ['fn' => $fn, 'fnn' => $fnn, 'argcount' => $arg_count]; // send function to output + if (in_array($fnn, $this->fb)) { // check the argument count + if ($arg_count > 1) { + throw new InvalidArgumentCountException(); + } + } elseif (isset($this->fc[$fnn])) { + $counts = $this->fc[$fnn]; + if (in_array(-1, $counts) and $arg_count > 0) { + } elseif (!in_array($arg_count, $counts)) { + throw new InvalidArgumentCountException(); + } + } elseif (array_key_exists($fnn, $this->f)) { + if ($arg_count != count($this->f[$fnn]['args'])) { + throw new InvalidArgumentCountException(); + } + } else { // did we somehow push a non-function on the stack? this should never happen + throw new InternalErrorException(); + } + } + $index++; + //=============== + } elseif ($op == ',' and $expecting_op) { // did we just finish a function argument? + while (($o2 = $stack->pop()) != '(') { + if (null === $o2) { + throw new UnexpectedTokenException([':token'=>',']); + } // oops, never had a ( + else { + $output[] = $o2; + } // pop the argument expression stuff and push onto the output + } + // make sure there was a function +// if (!preg_match("/^([A-Za-z]\w*)\($/", $stack->last(2), $matches)) { + if (!preg_match('/^('.self::NAME_PATTERN.')\($/', $stack->last(2) ?? '', $matches)) { + throw new UnexpectedTokenException([':token'=>',']); + } + $stack->push($stack->pop()+1); // increment the argument count + $stack->push('('); // put the ( back on, we'll need to pop back to it again + $index++; + $expecting_op = false; + //=============== + } elseif ($op == '(' and !$expecting_op) { + $stack->push('('); // that was easy + $index++; + $allow_neg = true; + //=============== + } elseif ($ex and !$expecting_op) { // do we now have a function/variable/number? + $expecting_op = true; + $val = $match[1]; +// if (preg_match("/^([A-Za-z]\w*)\($/", $val, $matches)) { // may be func, or variable w/ implicit multiplication against parentheses... + if (preg_match('/^('.static::NAME_PATTERN.')\($/', $val, $matches)) { // may be func, or variable w/ implicit multiplication against parentheses... + if (in_array($matches[1], $this->fb) or array_key_exists($matches[1], $this->f) or isset($this->fc[$matches[1]])) { // it's a func + $stack->push($val); + $stack->push(1); + $stack->push('('); + $expecting_op = false; + } else { // it's a var w/ implicit multiplication + $val = $matches[1]; + $output[] = $val; + } + } else { // it's a plain old var or num + $output[] = $val; + } + $index += strlen($val); + //=============== + } elseif ($op == ')') { // miscellaneous error checking + if ($stack->last() != '(' or $stack->last(2) != 1) { + throw new UnexpectedTokenException([':token' => ')']); + } + // did we just close a function? + if (preg_match('/^(' . static::NAME_PATTERN . ')\($/', $stack->last(3) ?? '', $matches)) { + $stack->pop();// ( + $stack->pop();// 1 + $fn = $stack->pop(); + $fnn = $matches[1]; // get the function name + $counts = $this->fc[$fnn]; + if (!in_array(0, $counts)) { + throw new InvalidArgumentCountException([':given' => 0, ':accept' => $counts]); + } + $output[] = ['fn' => $fn, 'fnn' => $fnn, 'argcount' => 0]; // send function to output + $index++; + $expecting_op = true; + } + throw new UnexpectedTokenException([':token' => ')']); + } elseif (in_array($op, $ops) and !$expecting_op) { + throw new UnexpectedOperatorException([':operator'=>$op]); + } else { // I don't even want to know what you did to get here + throw new InternalErrorException(); + } + if ($index == strlen($expr)) { + if (in_array($op, $ops)) { // did we end with an operator? bad. + throw new OperatorLacksOperandException([':operator' => $op]); + } + break; + } + while (substr($expr, $index, 1) == ' ') { // step the index past whitespace (pretty much turns whitespace + $index++; // into implicit multiplication if no operator is there) + } + + } + while (!is_null($op = $stack->pop())) { // pop everything off the stack and push onto output + if ($op == '(') { // if there are (s on the stack, ()s were unbalanced + throw new ExpectingTokenException([':token' => ')']); + } + $output[] = $op; + } + + return $output; + } + + /** + * evaluate postfix notation + * + * @param $tokens + * @param array $vars + * @return bool|mixed|null + * @throws DivisionByZeroException + * @throws InternalErrorException + * @throws UndefinedVariableException + */ + public function pfx($tokens, $vars = []) + { + if ($tokens == false) { + return false; + } + + $stack = new Stack(); + + foreach ($tokens as $token) { // nice and easy + if(is_array($token)) { // it's a function + $fnn = $token['fnn']; + $argcount = $token['argcount']; + if (in_array($fnn, $this->fb)) { // built-in function + if (is_null($op1 = $stack->pop())) { + throw new InternalErrorException(); + } + $fnn = preg_replace("/^arc/", "a", $fnn); // for the 'arc' trig synonyms + if ($fnn == 'ln') { + $fnn = 'log'; + } + eval('$stack->push(' . $fnn . '($op1));'); // perfectly safe eval() + } elseif (($m = $this->fc->findByName($fnn))) { + $args = []; + for ($i = $argcount-1; $i >= 0; $i--) { + if (null === ($args[] = $stack->pop())) { + throw new InternalErrorException(); + } + } + $res = $m->evaluate(...array_reverse($args)); + if ($res === FALSE) { + throw new InternalErrorException(); + } + $stack->push($res); + }elseif (array_key_exists($fnn, $this->f)) { // user function + // get args + $args = []; + for ($i = count($this->f[$fnn]['args'])-1; $i >= 0; $i--) { + if ( null === ($args[$this->f[$fnn]['args'][$i]] = $stack->pop())) { + throw new InternalErrorException(); + } + } + $stack->push($this->pfx($this->f[$fnn]['func'], $args)); // yay... recursion!!!! + } +// if the token is a binary operator, pop two values off the stack, do the operation, and push the result back on + } elseif (in_array($token, ['+', '-', '*', '/', '^', '>', '<', '==', '<=', '>=', '%'], true)) { + if (is_null($op2 = $stack->pop())) { + throw new InternalErrorException(); + } + if (is_null($op1 = $stack->pop())) { + throw new InternalErrorException(); + } + switch ($token) { + case '+': + $stack->push($op1 + $op2); + break; + case '-': + $stack->push($op1 - $op2); + break; + case '*': + $stack->push($op1 * $op2); + break; + case '/': + if ($op2 == 0) { + throw new DivisionByZeroException(); + } + $stack->push($op1 / $op2); + break; + case '^': + $stack->push(pow($op1, $op2)); + break; + case '>': + $stack->push((int)($op1 > $op2)); + break; + case '<': + $stack->push((int)($op1 < $op2)); + break; + case '==': + $stack->push((int)($op1 == $op2)); + break; + case '<=': + $stack->push((int)($op1 <= $op2)); + break; + case '>=': + $stack->push((int)($op1 >= $op2)); + break; + case '%': + $stack->push($op1 % $op2); + break; + } + + // if the token is a unary operator, pop one value off the stack, do the operation, and push it back on + } elseif ($token == "_") { + $stack->push(-1*$stack->pop()); + // if the token is a number or variable, push it on the stack + } else { + if (is_numeric($token)) { + $stack->push($token); + } elseif (array_key_exists($token, $this->v)) { + $stack->push($this->v[$token]); + } elseif (array_key_exists($token, $vars)) { + $stack->push($vars[$token]); + } else { + throw new UndefinedVariableException(); + } + } + } // when we're out of tokens, the stack should have a single element, the final result + if ($stack->count != 1) { + throw new InternalErrorException(); + } + return $stack->pop(); + } +} diff --git a/vendor/andileco/eval-math/src/Exception/AbstractEvalMathException.php b/vendor/andileco/eval-math/src/Exception/AbstractEvalMathException.php new file mode 100644 index 00000000..8cee91aa --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/AbstractEvalMathException.php @@ -0,0 +1,21 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + +use Exception; + +class AbstractEvalMathException extends Exception implements EvalMathException +{ + protected $error = ''; + + public function __construct(array $parameters=[]) + { + $message = strtr($this->error, $parameters); + parent::__construct($message); + } +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/BuiltInFunctionRedefinitionException.php b/vendor/andileco/eval-math/src/Exception/BuiltInFunctionRedefinitionException.php new file mode 100644 index 00000000..c71c7954 --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/BuiltInFunctionRedefinitionException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class BuiltInFunctionRedefinitionException extends AbstractEvalMathException +{ + protected $message = 'Cannot redefine built-in function :function()'; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/ConstantAssignmentException.php b/vendor/andileco/eval-math/src/Exception/ConstantAssignmentException.php new file mode 100644 index 00000000..b4ab14ba --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/ConstantAssignmentException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class ConstantAssignmentException extends AbstractEvalMathException +{ + protected $message = 'Cannot assign to constant :constant'; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/DivisionByZeroException.php b/vendor/andileco/eval-math/src/Exception/DivisionByZeroException.php new file mode 100644 index 00000000..ec59a65b --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/DivisionByZeroException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class DivisionByZeroException extends AbstractEvalMathException +{ + protected $message = 'Division by zero'; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/EvalMathException.php b/vendor/andileco/eval-math/src/Exception/EvalMathException.php new file mode 100644 index 00000000..e14514c6 --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/EvalMathException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +interface EvalMathException +{ + +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/ExpectingTokenException.php b/vendor/andileco/eval-math/src/Exception/ExpectingTokenException.php new file mode 100644 index 00000000..faf4b1ed --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/ExpectingTokenException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class ExpectingTokenException extends AbstractEvalMathException +{ + protected $message = "Expecting ':token'"; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/IllegalCharacterException.php b/vendor/andileco/eval-math/src/Exception/IllegalCharacterException.php new file mode 100644 index 00000000..517e97d2 --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/IllegalCharacterException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class IllegalCharacterException extends AbstractEvalMathException +{ + protected $message = "illegal character ':character'"; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/InternalErrorException.php b/vendor/andileco/eval-math/src/Exception/InternalErrorException.php new file mode 100644 index 00000000..d700f86a --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/InternalErrorException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class InternalErrorException extends AbstractEvalMathException +{ + protected $message = 'Internal error'; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/InvalidArgumentCountException.php b/vendor/andileco/eval-math/src/Exception/InvalidArgumentCountException.php new file mode 100644 index 00000000..3c3eef0e --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/InvalidArgumentCountException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class InvalidArgumentCountException extends AbstractEvalMathException +{ + protected $message = 'Wrong number of arguments'; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/OperatorLacksOperandException.php b/vendor/andileco/eval-math/src/Exception/OperatorLacksOperandException.php new file mode 100644 index 00000000..7b265a55 --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/OperatorLacksOperandException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class OperatorLacksOperandException extends AbstractEvalMathException +{ + protected $message = "operator ':operator' lacks operand"; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/OperatorRequiredException.php b/vendor/andileco/eval-math/src/Exception/OperatorRequiredException.php new file mode 100644 index 00000000..478c1d8c --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/OperatorRequiredException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class OperatorRequiredException extends AbstractEvalMathException +{ + protected $message = 'Operator required'; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/UndefinedVariableException.php b/vendor/andileco/eval-math/src/Exception/UndefinedVariableException.php new file mode 100644 index 00000000..aeda753b --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/UndefinedVariableException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class UndefinedVariableException extends AbstractEvalMathException +{ + protected $message = "Undefined variable"; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/UndefinedVariableInFunctionDefinitionException.php b/vendor/andileco/eval-math/src/Exception/UndefinedVariableInFunctionDefinitionException.php new file mode 100644 index 00000000..0c03e0d1 --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/UndefinedVariableInFunctionDefinitionException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class UndefinedVariableInFunctionDefinitionException extends AbstractEvalMathException +{ + protected $message = 'Undefined variable :token in function definition'; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/UnexpectedOperatorException.php b/vendor/andileco/eval-math/src/Exception/UnexpectedOperatorException.php new file mode 100644 index 00000000..5545d04e --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/UnexpectedOperatorException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class UnexpectedOperatorException extends AbstractEvalMathException +{ + protected $message = 'Unexpected operator \':operator\''; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Exception/UnexpectedTokenException.php b/vendor/andileco/eval-math/src/Exception/UnexpectedTokenException.php new file mode 100644 index 00000000..4766f3b7 --- /dev/null +++ b/vendor/andileco/eval-math/src/Exception/UnexpectedTokenException.php @@ -0,0 +1,14 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Exception; + + +class UnexpectedTokenException extends AbstractEvalMathException +{ + protected $message = 'Unexpected \':token\''; +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Methods/AbstractMethod.php b/vendor/andileco/eval-math/src/Methods/AbstractMethod.php new file mode 100644 index 00000000..4bf19e2a --- /dev/null +++ b/vendor/andileco/eval-math/src/Methods/AbstractMethod.php @@ -0,0 +1,34 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Methods; + + +abstract class AbstractMethod +{ + protected $name = ''; + + protected $argument_count = [-1]; + + public function getName() + { + return $this->name; + } + + public function setName($name) + { + $this->name = $name; + return clone $this; + } + + public function getArgumentCount() + { + return $this->argument_count; + } + + abstract public function evaluate(...$args); +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Methods/Conditional.php b/vendor/andileco/eval-math/src/Methods/Conditional.php new file mode 100644 index 00000000..2c8fa594 --- /dev/null +++ b/vendor/andileco/eval-math/src/Methods/Conditional.php @@ -0,0 +1,20 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Methods; + + +class Conditional extends AbstractMethod +{ + protected $name='if'; + protected $argument_count = [3]; + + public function evaluate(...$args) + { + return $args[0] ? $args[1] : $args[2]; + } +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Methods/Maximum.php b/vendor/andileco/eval-math/src/Methods/Maximum.php new file mode 100644 index 00000000..f81af973 --- /dev/null +++ b/vendor/andileco/eval-math/src/Methods/Maximum.php @@ -0,0 +1,19 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Methods; + + +class Maximum extends AbstractMethod +{ + protected $name='max'; + + public function evaluate(...$args) + { + return max($args); + } +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Methods/Minimum.php b/vendor/andileco/eval-math/src/Methods/Minimum.php new file mode 100644 index 00000000..6f694140 --- /dev/null +++ b/vendor/andileco/eval-math/src/Methods/Minimum.php @@ -0,0 +1,19 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Methods; + + +class Minimum extends AbstractMethod +{ + protected $name = 'min'; + + public function evaluate(...$args) + { + return min($args); + } +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/src/Methods/Round.php b/vendor/andileco/eval-math/src/Methods/Round.php new file mode 100644 index 00000000..8702efb8 --- /dev/null +++ b/vendor/andileco/eval-math/src/Methods/Round.php @@ -0,0 +1,18 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath; + + +use ArrayAccess; +use Countable; +use RuntimeException; +use Andileco\Util\EvalMath\Methods\AbstractMethod; + +class MethodsRegistry implements ArrayAccess, Countable +{ + protected $methods = []; + + public function set(AbstractMethod $method) + { + $this->methods[$method->getName()] = clone $method; + return $this; + } + + public function unsetByName($name) + { + unset($this->methods[$name]); + return $this; + } + + /** + * @param $name + * @return AbstractMethod|null + */ + public function findByName($name) + { + return (isset($this->methods[$name]) and $this->methods[$name] instanceof AbstractMethod) ? $this->methods[$name] : null; + } + + public function offsetExists($offset): bool + { + return isset($this->methods[$offset]); + } + + public function offsetGet($offset): mixed + { + $m = $this->findByName($offset); + return $m ? $m->getArgumentCount() : null; + } + + public function offsetSet($offset, $value): void + { + throw new RuntimeException('Use set() method instead'); + } + + public function offsetUnset($offset): void + { + $this->unsetByName($offset); + } + + public function count(): int + { + return count($this->methods); + } +} diff --git a/vendor/andileco/eval-math/src/Stack.php b/vendor/andileco/eval-math/src/Stack.php new file mode 100644 index 00000000..b93a7238 --- /dev/null +++ b/vendor/andileco/eval-math/src/Stack.php @@ -0,0 +1,45 @@ +stack[$this->count] = $val; + $this->count++; + } + + public function pop() + { + if ($this->count > 0) { + $this->count--; + return $this->stack[$this->count]; + } + + return null; + } + + public function last($n=1) + { + $key = $this->count - $n; + + return array_key_exists($key,$this->stack) ? $this->stack[$key] : null; + } +} diff --git a/vendor/andileco/eval-math/tests/EvalMathExceptionsTest.php b/vendor/andileco/eval-math/tests/EvalMathExceptionsTest.php new file mode 100644 index 00000000..e61ee57c --- /dev/null +++ b/vendor/andileco/eval-math/tests/EvalMathExceptionsTest.php @@ -0,0 +1,216 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Tests; + +use PHPUnit\Framework\TestCase; +use Andileco\Util\EvalMath\EvalMath; +use Andileco\Util\EvalMath\Exception\AbstractEvalMathException; +use Andileco\Util\EvalMath\Exception\BuiltInFunctionRedefinitionException; +use Andileco\Util\EvalMath\Exception\ConstantAssignmentException; +use Andileco\Util\EvalMath\Exception\DivisionByZeroException; +use Andileco\Util\EvalMath\Exception\ExpectingTokenException; +use Andileco\Util\EvalMath\Exception\IllegalCharacterException; +use Andileco\Util\EvalMath\Exception\InternalErrorException; +use Andileco\Util\EvalMath\Exception\InvalidArgumentCountException; +use Andileco\Util\EvalMath\Exception\OperatorLacksOperandException; +use Andileco\Util\EvalMath\Exception\OperatorRequiredException; +use Andileco\Util\EvalMath\Exception\UndefinedVariableException; +use Andileco\Util\EvalMath\Exception\UndefinedVariableInFunctionDefinitionException; +use Andileco\Util\EvalMath\Exception\UnexpectedOperatorException; +use Andileco\Util\EvalMath\Exception\UnexpectedTokenException; + +class EvalMathExceptionsTest extends TestCase +{ + private $EvalMath; + + protected function setUp() + { + $this->EvalMath = new EvalMath(); + } + + /** + * @throws AbstractEvalMathException + */ + public function testBuiltInFunctionRedefinition() + { + $this->expectException(BuiltInFunctionRedefinitionException::class); + $this->EvalMath->evaluate('cos(x) = x*2'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testConstantAssignmentException() + { + $this->expectException(ConstantAssignmentException::class); + $this->EvalMath->evaluate('pi = 123'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testDivisionByZero() + { + $this->expectException(DivisionByZeroException::class); + $this->EvalMath->evaluate('5/0'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testExpectingTokenException() + { + $this->expectException(ExpectingTokenException::class); + $this->EvalMath->evaluate('p = 6*(3+1'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testIllegalCharacterException() + { + $this->expectException(IllegalCharacterException::class); + $this->EvalMath->evaluate('$v = 5'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testInternalErrorException() + { + $this->expectException(InternalErrorException::class); + $this->EvalMath->e('k='); + } + + /** + * @throws AbstractEvalMathException + */ + public function testInvalidArgumentCountException() + { + $this->expectException(InvalidArgumentCountException::class); + $this->EvalMath->e('a=sin(5,7,6)'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testInvalidArgumentFuncCountException() + { + $this->expectException(InvalidArgumentCountException::class); + $this->EvalMath->e('iif(1)'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testInvalidArgumentFuncCountException2() + { + $this->expectException(InvalidArgumentCountException::class); + $this->EvalMath->e('iif()'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testInvalidArgumentUserFuncCountException() + { + $this->expectException(InvalidArgumentCountException::class); + $this->EvalMath->e('f(a,b) = a+b'); + $this->EvalMath->e('f(1)'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testOperatorLacksOperandException() + { + $this->expectException(OperatorLacksOperandException::class); + $this->EvalMath->e('k=5+'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testOperatorRequiredException() + { + $this->expectException(OperatorRequiredException::class); + $this->EvalMath->e('a=5(7+1)'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testUndefinedVariableException() + { + $this->expectException(UndefinedVariableException::class); + $this->EvalMath->e('b=a+1'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testUndefinedVariableInFunctionDefinitionException() + { + $this->expectException(UndefinedVariableInFunctionDefinitionException::class); + $this->EvalMath->e('f(a) = b*3'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testUnexpectedOperatorException() + { + $this->expectException(UnexpectedOperatorException::class); + $this->EvalMath->e('1 + * 2'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testUnexpectedTokenException() + { + $this->expectException(UnexpectedTokenException::class); + $this->EvalMath->e('a=(3+)'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testUnexpectedClosingPrenticeException() + { + $this->expectException(UnexpectedTokenException::class); + $this->EvalMath->e('a=5)'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testUnexpectedComma() + { + $this->expectException(UnexpectedTokenException::class); + $this->EvalMath->e('a=5,3)'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testUnexpectedComma2() + { + $this->expectException(UnexpectedTokenException::class); + $this->EvalMath->e('a=sin(0.3),3)'); + } + + /** + * @throws AbstractEvalMathException + */ + public function testUnexpectedComma3() + { + $this->expectException(UnexpectedTokenException::class); + $this->EvalMath->e('a=sin(0.1,)'); + } +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/tests/EvalMathTest.php b/vendor/andileco/eval-math/tests/EvalMathTest.php new file mode 100644 index 00000000..360140fd --- /dev/null +++ b/vendor/andileco/eval-math/tests/EvalMathTest.php @@ -0,0 +1,140 @@ + + * Created on 02 12, 2016, 17:17 + * Copyright (C) 8x8 + */ + +namespace Andileco\Util\EvalMath\Tests; + +use PHPUnit\Framework\TestCase; +use Andileco\Util\EvalMath\EvalMath; + +class EvalMathTest extends TestCase +{ + /** + * @var EvalMath + */ + private $evalMath; + + protected function setUp() + { + $this->evalMath = new EvalMath(); + } + + /** + * @test + * @dataProvider moduloOperatorData + */ + public function shouldSupportModuloOperator($formula, $values, $expectedResult) + { + foreach ($values as $k => $v) { + $this->evalMath->v[$k] = $v; + } + + $this->assertEquals($expectedResult, $this->evalMath->evaluate($formula)); + } + + public function moduloOperatorData() + { + return array( + array( + 'a%b', // 9%3 => 0 + array('a' => 9, 'b' => 3), + 0 + ), + array( + 'a%b', // 10%3 => 1 + array('a' => 10, 'b' => 3), + 1 + ), + array( + '10-a%(b+c*d)', // 10-10%(7-2*2) => 9 + array('a' => '10', 'b' => 7, 'c'=> -2, 'd' => 2), + 9 + ) + ); + } + + /** + * @test + * @dataProvider doubleMinusData + */ + public function shouldConsiderDoubleMinusAsPlus($formula, $values, $expectedResult) + { + foreach ($values as $k => $v) { + $this->evalMath->v[$k] = $v; + } + + $this->assertEquals( + $expectedResult, + $this->evalMath->evaluate($formula) + ); + } + + public function doubleMinusData() + { + return array( + array( + 'a+b*c--d', // 1+2*3--4 => 1+6+4 => 11 + array( + 'a' => 1, + 'b' => 2, + 'c' => 3, + 'd' => 4 + ), + 11 + ), + array( + 'a+b*c--d', // 1+2*3---4 => 1+6-4 => 3 + array( + 'a' => 1, + 'b' => 2, + 'c' => 3, + 'd' => -4 + ), + 3 + ) + ); + } + + /** + * @dataProvider maximumsData + */ + public function testMaxCalcFunction($formula, $expected) + { + $this->assertEquals($expected, $this->evalMath->e($formula)); + } + + public function maximumsData() + { + return [ + ['max(1,2,3,4,5)', 5], + ['max(5,2,3,1,4)', 5], + ['max(5)', 5], + ['max(5,5)', 5] + ]; + } + + /** + * @dataProvider comparisonsData + */ + public function testComparisonOperators($formula, $expected) + { + $this->assertEquals($expected, $this->evalMath->e($formula)); + } + + public function comparisonsData() + { + return [ + ['1==1', 1], + ['1==2', 0], + ['1>2', 0], + ['1<2', 1], + ['1>=1', 1], + ['1<=2', 1] + ]; + } +} diff --git a/vendor/andileco/eval-math/tests/MethodRegistryTest.php b/vendor/andileco/eval-math/tests/MethodRegistryTest.php new file mode 100644 index 00000000..fcb9773c --- /dev/null +++ b/vendor/andileco/eval-math/tests/MethodRegistryTest.php @@ -0,0 +1,63 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Tests; + + +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Andileco\Util\EvalMath\Methods\Maximum; +use Andileco\Util\EvalMath\MethodsRegistry; + +class MethodRegistryTest extends TestCase +{ + private $registry; + + protected function setUp() + { + $this->registry = new MethodsRegistry(); + } + + public function testSetFindUnset() + { + $r = $this->registry->set(new Maximum); + $this->assertInstanceOf(MethodsRegistry::class, $r); + + $c = $this->registry->findByName('max'); + $this->assertInstanceOf(Maximum::class, $c); + $this->assertTrue(isset($this->registry['max'])); + + $c = $this->registry['max']; + $this->assertTrue(is_array($c)); +// $this->assertInstanceOf(Maximum::class, $c); + + $this->assertEquals(1, $this->registry->count()); + unset($this->registry['max']); + $this->assertEquals(0, $this->registry->count()); + + + $r = $this->registry->unsetByName('max'); + $this->assertInstanceOf(MethodsRegistry::class, $r); + } + + /** + * @expectedException RuntimeException + */ + public function testOffsetSetException() + { + $this->registry['max'] = new Maximum(); + } + + public function testAliases() + { + $this->registry = new MethodsRegistry(); + $this->registry->set(new Maximum())->set((new Maximum)->setName('mmaaxx')); + $this->assertEquals(2, $this->registry->count()); + $this->assertTrue(isset($this->registry['max'])); + $this->assertTrue(isset($this->registry['mmaaxx'])); + } +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/tests/MethodsTest.php b/vendor/andileco/eval-math/tests/MethodsTest.php new file mode 100644 index 00000000..65a13824 --- /dev/null +++ b/vendor/andileco/eval-math/tests/MethodsTest.php @@ -0,0 +1,69 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Tests; + +use PHPUnit\Framework\TestCase; +use Andileco\Util\EvalMath\Methods\Conditional; +use Andileco\Util\EvalMath\Methods\Maximum; +use Andileco\Util\EvalMath\Methods\Minimum; +use Andileco\Util\EvalMath\Methods\Round; + +class MethodsTest extends TestCase +{ + public function testConditional() + { + $m = new Conditional(); + $this->assertEquals('if', $m->getName()); + $this->assertEquals([3], $m->getArgumentCount()); + $this->assertEquals(1, $m->evaluate(true, 1, 0)); + $this->assertEquals(0, $m->evaluate(false, 1, 0)); + } + + public function testMaximum() + { + $m = new Maximum(); + $this->assertEquals('max', $m->getName()); + $this->assertEquals([-1], $m->getArgumentCount()); + $this->assertEquals(1, $m->evaluate(1, 0)); + $this->assertEquals(10, $m->evaluate(false, 1, 0, 10)); + $this->assertEquals(1, $m->evaluate(1)); + } + + public function testMinimum() + { + $m = new Minimum(); + $this->assertEquals('min', $m->getName()); + $this->assertEquals([-1], $m->getArgumentCount()); + $this->assertEquals(0, $m->evaluate(1, 0)); + $this->assertEquals(0, $m->evaluate(false, 1, 0, 10)); + $this->assertEquals(1, $m->evaluate(1)); + } + + public function testRound() + { + $m = new Round(); + $this->assertEquals('round', $m->getName()); + $this->assertEquals([-1], $m->getArgumentCount()); + + $this->assertEquals(2, $m->evaluate(1.5321)); + $this->assertEquals(1, $m->evaluate(1.4321)); + $this->assertEquals(1, $m->evaluate(1.4921)); + + $this->assertEquals(git add .1.57, $m->evaluate(1.5678, 2)); + $this->assertEquals(1.568, $m->evaluate(1.5678, 3)); + $this->assertEquals(1.5678, $m->evaluate(1.5678, 4)); + + $this->assertEquals(1.54, $m->evaluate(1.5378, 2)); + $this->assertEquals(1.538, $m->evaluate(1.5378, 3)); + $this->assertEquals(1.5378, $m->evaluate(1.5378, 4)); + + $this->assertEquals(1.53, $m->evaluate(1.5321, 2)); + $this->assertEquals(1.532, $m->evaluate(1.5321, 3)); + $this->assertEquals(1.5321, $m->evaluate(1.5321, 4)); + } +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/tests/UserFunctionsTest.php b/vendor/andileco/eval-math/tests/UserFunctionsTest.php new file mode 100644 index 00000000..e2db340c --- /dev/null +++ b/vendor/andileco/eval-math/tests/UserFunctionsTest.php @@ -0,0 +1,24 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Tests; + + +use PHPUnit\Framework\TestCase; +use Andileco\Util\EvalMath\EvalMath; + +class UserFunctionsTest extends TestCase +{ + public function testFunctionsDump() + { + $e = new EvalMath(); + $this->assertEmpty($e->funcs()); + + $e->e('f(x)=2*x'); + $this->assertEquals(1, count($e->funcs())); + } +} \ No newline at end of file diff --git a/vendor/andileco/eval-math/tests/UserVariablesTest.php b/vendor/andileco/eval-math/tests/UserVariablesTest.php new file mode 100644 index 00000000..ed9d1f87 --- /dev/null +++ b/vendor/andileco/eval-math/tests/UserVariablesTest.php @@ -0,0 +1,25 @@ + + * @copyright Serge Rodovnichenko, 2019 + * @license BSD 2.0 + */ + +namespace Andileco\Util\EvalMath\Tests; + + +use PHPUnit\Framework\TestCase; +use Andileco\Util\EvalMath\EvalMath; + +class UserVariablesTest extends TestCase +{ + public function testVariablesDump() + { + $e = new EvalMath(); + $this->assertEmpty($e->vars()); + + $e->evaluate('a=1'); + $this->assertArrayHasKey('a',$e->vars()); + $this->assertEquals('1', $e->vars()['a']); + } +} \ No newline at end of file diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php index af55a9ab..e6f9163a 100644 --- a/vendor/composer/autoload_psr4.php +++ b/vendor/composer/autoload_psr4.php @@ -120,4 +120,5 @@ return array( 'Behat\\Mink\\Driver\\' => array($vendorDir . '/behat/mink-browserkit-driver/src', $vendorDir . '/behat/mink-selenium2-driver/src'), 'Behat\\Mink\\' => array($vendorDir . '/behat/mink/src'), 'Asm89\\Stack\\' => array($vendorDir . '/asm89/stack-cors/src'), + 'Andileco\\Util\\EvalMath\\' => array($vendorDir . '/andileco/eval-math/src'), ); diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 86dcb155..ca489368 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -210,6 +210,7 @@ class ComposerStaticInit103f9b8db31610ca1687c620aeec857d 'A' => array ( 'Asm89\\Stack\\' => 12, + 'Andileco\\Util\\EvalMath\\' => 23, ), ); @@ -675,6 +676,10 @@ class ComposerStaticInit103f9b8db31610ca1687c620aeec857d array ( 0 => __DIR__ . '/..' . '/asm89/stack-cors/src', ), + 'Andileco\\Util\\EvalMath\\' => + array ( + 0 => __DIR__ . '/..' . '/andileco/eval-math/src', + ), ); public static $prefixesPsr0 = array ( diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index 62f82ccd..18a3fb33 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -1,5 +1,54 @@ { "packages": [ + { + "name": "andileco/eval-math", + "version": "3.0.1", + "version_normalized": "3.0.1.0", + "source": { + "type": "git", + "url": "https://github.com/andileco/eval-math.git", + "reference": "52ff5728ad51961a630cc957328764cb02ab5c51" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andileco/eval-math/zipball/52ff5728ad51961a630cc957328764cb02ab5c51", + "reference": "52ff5728ad51961a630cc957328764cb02ab5c51", + "shasum": "" + }, + "require": { + "php": "^7 || ^8.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2", + "phpunit/phpunit": "^5" + }, + "time": "2022-07-21T21:04:26+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Andileco\\Util\\EvalMath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "EvalMath", + "keywords": [ + "EvalMath" + ], + "support": { + "issues": "https://github.com/andileco/eval-math/issues", + "source": "https://github.com/andileco/eval-math/tree/3.0.1" + }, + "install-path": "../andileco/eval-math" + }, { "name": "asm89/stack-cors", "version": "v2.2.0", @@ -1855,6 +1904,61 @@ }, "install-path": "../../web/modules/admin_toolbar" }, + { + "name": "drupal/charts", + "version": "5.1.1", + "version_normalized": "5.1.1.0", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/charts.git", + "reference": "5.1.1" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/charts-5.1.1.zip", + "reference": "5.1.1", + "shasum": "81529ec926462ddc62e9f9f78d47b036af566b6b" + }, + "require": { + "drupal/core": "^10.3 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "5.1.1", + "datestamp": "1733170631", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "installation-source": "dist", + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "andileco", + "homepage": "https://www.drupal.org/user/2054544" + }, + { + "name": "nikathone", + "homepage": "https://www.drupal.org/user/2421800" + }, + { + "name": "quicksketch", + "homepage": "https://www.drupal.org/user/35821" + } + ], + "description": "A charting API for Drupal that provides chart elements and integration with Views.", + "homepage": "https://www.drupal.org/project/charts", + "support": { + "source": "https://git.drupalcode.org/project/charts" + }, + "install-path": "../../web/modules/charts" + }, { "name": "drupal/coder", "version": "8.3.26", @@ -4111,6 +4215,59 @@ }, "install-path": "../../web/modules/views_geojson" }, + { + "name": "drupal/views_simple_math_field", + "version": "3.0.2", + "version_normalized": "3.0.2.0", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/views_simple_math_field.git", + "reference": "3.0.2" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/views_simple_math_field-3.0.2.zip", + "reference": "3.0.2", + "shasum": "6620ee614bbaf68b9c26227e01c74299be47a6d8" + }, + "require": { + "andileco/eval-math": "^3.0.1", + "drupal/core": "^8.7|^9.0 || ^10 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "3.0.2", + "datestamp": "1719379402", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "installation-source": "dist", + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "andileco", + "homepage": "https://www.drupal.org/user/2054544" + }, + { + "name": "Beakerboy", + "homepage": "https://www.drupal.org/user/1700098" + } + ], + "description": "Math operations over selected fields", + "homepage": "https://www.drupal.org/project/views_simple_math_field", + "support": { + "source": "http://cgit.drupalcode.org/views_simple_math_field", + "issues": "https://www.drupal.org/project/issues/views_simple_math_field" + }, + "install-path": "../../web/modules/views_simple_math_field" + }, { "name": "drush/drush", "version": "12.5.3", diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 44a8ec28..9dc7e001 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -1,15 +1,24 @@ array( 'name' => 'farmos/project', - 'pretty_version' => '1.0.0+no-version-set', - 'version' => '1.0.0.0', - 'reference' => null, + 'pretty_version' => 'dev-main', + 'version' => 'dev-main', + 'reference' => '62a5f3c97c55bae97ffe5b46a8cdb83a78581319', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev' => true, ), 'versions' => array( + 'andileco/eval-math' => array( + 'pretty_version' => '3.0.1', + 'version' => '3.0.1.0', + 'reference' => '52ff5728ad51961a630cc957328764cb02ab5c51', + 'type' => 'library', + 'install_path' => __DIR__ . '/../andileco/eval-math', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'asm89/stack-cors' => array( 'pretty_version' => 'v2.2.0', 'version' => '2.2.0.0', @@ -244,6 +253,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'drupal/charts' => array( + 'pretty_version' => '5.1.1', + 'version' => '5.1.1.0', + 'reference' => '5.1.1', + 'type' => 'drupal-module', + 'install_path' => __DIR__ . '/../../web/modules/charts', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'drupal/coder' => array( 'pretty_version' => '8.3.26', 'version' => '8.3.26.0', @@ -685,6 +703,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'drupal/views_simple_math_field' => array( + 'pretty_version' => '3.0.2', + 'version' => '3.0.2.0', + 'reference' => '3.0.2', + 'type' => 'drupal-module', + 'install_path' => __DIR__ . '/../../web/modules/views_simple_math_field', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'drush/drush' => array( 'pretty_version' => '12.5.3', 'version' => '12.5.3.0', @@ -740,9 +767,9 @@ 'dev_requirement' => false, ), 'farmos/project' => array( - 'pretty_version' => '1.0.0+no-version-set', - 'version' => '1.0.0.0', - 'reference' => null, + 'pretty_version' => 'dev-main', + 'version' => 'dev-main', + 'reference' => '62a5f3c97c55bae97ffe5b46a8cdb83a78581319', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), diff --git a/web/modules/charts/.gitlab-ci.yml b/web/modules/charts/.gitlab-ci.yml new file mode 100644 index 00000000..e73eecb5 --- /dev/null +++ b/web/modules/charts/.gitlab-ci.yml @@ -0,0 +1,97 @@ +################ +# DrupalCI GitLabCI template +# +# Gitlab-ci.yml to replicate DrupalCI testing for Contrib +# +# With thanks to: +# * The GitLab Acceleration Initiative participants +# * DrupalSpoons +################ + +################ +# Guidelines +# +# This template is designed to give any Contrib maintainer everything they need to test, without requiring modification. It is also designed to keep up to date with Core Development automatically through the use of include files that can be centrally maintained. +# +# However, you can modify this template if you have additional needs for your project. +################ + +################ +# Includes +# +# Additional configuration can be provided through includes. +# One advantage of include files is that if they are updated upstream, the changes affect all pipelines using that include. +# +# Includes can be overridden by re-declaring anything provided in an include, here in gitlab-ci.yml +# https://docs.gitlab.com/ee/ci/yaml/includes.html#override-included-configuration-values +################ + +include: + ################ + # DrupalCI includes: + # As long as you include this, any future includes added by the Drupal Association will be accessible to your pipelines automatically. + # View these include files at https://git.drupalcode.org/project/gitlab_templates/ + ################ + - project: $_GITLAB_TEMPLATES_REPO + ref: $_GITLAB_TEMPLATES_REF + file: + - '/includes/include.drupalci.main.yml' + - '/includes/include.drupalci.variables.yml' + - '/includes/include.drupalci.workflows.yml' + +################ +# Pipeline configuration variables +# +# These are the variables provided to the Run Pipeline form that a user may want to override. +# +# Docs at https://git.drupalcode.org/project/gitlab_templates/-/blob/1.0.x/includes/include.drupalci.variables.yml +################ +# variables: +# SKIP_ESLINT: '1' + + +################################################################################### +# +# * +# /( +# ((((, +# /((((((( +# ((((((((((* +# ,((((((((((((((( +# ,((((((((((((((((((( +# ((((((((((((((((((((((((* +# *((((((((((((((((((((((((((((( +# ((((((((((((((((((((((((((((((((((* +# *(((((((((((((((((( .(((((((((((((((((( +# ((((((((((((((((((. /(((((((((((((((((* +# /((((((((((((((((( .(((((((((((((((((, +# ,(((((((((((((((((( (((((((((((((((((( +# .(((((((((((((((((((( .((((((((((((((((( +# ((((((((((((((((((((((( ((((((((((((((((/ +# (((((((((((((((((((((((((((/ ,(((((((((((((((* +# .((((((((((((((/ /(((((((((((((. ,((((((((((((((( +# *(((((((((((((( ,(((((((((((((/ *((((((((((((((. +# ((((((((((((((, /(((((((((((((. ((((((((((((((, +# (((((((((((((/ ,(((((((((((((* ,(((((((((((((, +# *((((((((((((( .((((((((((((((( ,((((((((((((( +# ((((((((((((/ /((((((((((((((((((. ,((((((((((((/ +# ((((((((((((( *(((((((((((((((((((((((* *(((((((((((( +# ((((((((((((( ,(((((((((((((..((((((((((((( *(((((((((((( +# ((((((((((((, /((((((((((((* /((((((((((((/ (((((((((((( +# ((((((((((((( /((((((((((((/ (((((((((((((* (((((((((((( +# (((((((((((((/ /(((((((((((( ,((((((((((((, *(((((((((((( +# (((((((((((((( *(((((((((((/ *((((((((((((. ((((((((((((/ +# *((((((((((((((((((((((((((, /((((((((((((((((((((((((( +# ((((((((((((((((((((((((( ((((((((((((((((((((((((, +# .(((((((((((((((((((((((/ ,((((((((((((((((((((((( +# ((((((((((((((((((((((/ ,(((((((((((((((((((((/ +# *((((((((((((((((((((( (((((((((((((((((((((, +# ,(((((((((((((((((((((, ((((((((((((((((((((/ +# ,(((((((((((((((((((((* /(((((((((((((((((((( +# ((((((((((((((((((((((, ,/((((((((((((((((((((, +# ,((((((((((((((((((((((((((((((((((((((((((((((((((( +# .((((((((((((((((((((((((((((((((((((((((((((( +# .((((((((((((((((((((((((((((((((((((,. +# .,(((((((((((((((((((((((((. +# +################################################################################### diff --git a/web/modules/charts/LICENSE.txt b/web/modules/charts/LICENSE.txt new file mode 100644 index 00000000..d159169d --- /dev/null +++ b/web/modules/charts/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/web/modules/charts/README.md b/web/modules/charts/README.md new file mode 100755 index 00000000..83c404b8 --- /dev/null +++ b/web/modules/charts/README.md @@ -0,0 +1,242 @@ +# Charts + +Transform **data** into **information**. The Charts module for Drupal enables +you to build dynamic charts without needing to write a line of code. If you are +comfortable coding, you can use the Charts API to generate or update charts. +Instructions for creating charts are included below. + +There are many charting libraries (also sometimes referred to as providers) +that you can find online. Each has their own benefits, drawbacks, and APIs. To +use one of them **without** this module, you would need to 1) be familiar with +the charting library's API, 2) add its JavaScript file(s) in your page, and 3) +write code for every chart you want to include. That's a lot of effort, and it +requires knowledge and access that many Drupal site builders will not have. + +Charts is designed so that anyone who wants a chart on their site can have a +chart -- and be happy with it. + +## How Does The Charts Module Work (Technically)? + +The Charts module takes data and configuration from your site, and with the help +of a submodule, organizes it into a JSON object that is saved as an attribute +on an HTML element on your page; the JSON objected is handed to your selected +charting library, which renders it into a chart. + +## Charting Providers / Libraries + +Out of the box, you will be able to use 5 charting solutions (referred to as +"providers" or "libraries". Each of them has particular advantages and +disadvantages. + +* Billboard.js: This library is a fork of the C3 charting library. It has +additional features, such as radar charts. +* C3.js: This library is a D3-based reusable chart library that makes it easy + to generate D3-based charts. +* Chart.js: This is a simple yet flexible JavaScript charting for designers & + developers. +* Google Charts: This library generates interactive charts using SVG and VML. +* Highcharts: This library is one of the premier solutions for generating + charts. Although it is very powerful and aesthetically pleasing with smooth + animations, it requires a commercial license. It's free for non-commercial + use. See http://www.highcharts.com + +## Chart Types + +Each charting library has its own set of chart types. The Charts module +focuses on chart types that are available in *most* of the libraries. +To avoid a confusing user interface, the Charts module no longer includes +chart types that are not supported by the selected library in the Chart +Type selection. For example, if you select the Chart.js library, you will +not see the "Gauge" chart type. + +## Installing Libraries + +All the Charts submodules default to using a content delivery network (CDN) to +pull in the necessary JavaScript files unless a local copy is present, or you +have disabled the CDN option on the "Charts configuration" page +(`/admin/config/content/charts`). + +Below are a couple outlines for adding the libraries locally. + +### Using Composer + +1. Ensure that you have the `composer/installers` package installed. +2. Ensure you have an installer-paths for the drupal-library type. Such as in + the composer.json of + https://github.com/drupal-composer/drupal-project/blob/9.x/composer.json +3. In each submodule, there is a README.md file that has code to add to your + site's composer.json file. +4. After updating your project's composer.json file, run the 'composer require' + command specified in each submodule's README.md file. For example, if you are + using Google Charts, step three would mean adding: +```json +{ + "type": "package", + "package": { + "name": "google/charts", + "version": "45", + "type": "drupal-library", + "extra": { + "installer-name": "google_charts" + }, + "dist": { + "url": "https://www.gstatic.com/charts/loader.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } +} +``` + to your project's composer.json, and then running: + `composer require --prefer-dist google/charts:45` + +### Using Composer and wikimedia/composer-merge-plugin: + +1. Ensure that you have the `wikimedia/composer-merge-plugin` package + installed. +2. Ensure that you have the `oomphinc/composer-installers-extender` package + installed. +3. Add an "installer-types" section in the "extra" of your project + composer.json file, make sure you have "npm-asset" listed. For example: +```json +"installer-types": [ + "npm-asset" +], +``` +5. In the "installer-paths" section in the "extra" of your project + composer.json file, ensure you have the types drupal-library and npm-asset. + For example: +```json +"web/libraries/{$name}": [ + "type:drupal-library", + "type:npm-asset" +], +``` +6. Add a "merge-plugin" section in the "extra" of your project composer.json + file, so that the composer.json file of the submodules you want is included. + For example: +```json +"merge-plugin": { + "include": [ + "web/modules/contrib/charts/modules/charts_highcharts/composer.json" + ] +}, +``` +7. Run the `composer require` specified in the submodule's README.md file + +## Creating Charts in the UI + +This module provides a configuration page at /admin/config/content/charts. +You may set site-wide defaults on this page (for example: set the default color +scheme). It also has library-specific configuration available after saving a +default library. + +There are three options for creating a chart within the UI: +1. Using Views +2. Using a Chart Field +3. Using a Chart Block + +### Creating Charts with Views + +1. Create a new view: + Visit admin/structure/views/add and select the display format of "Chart" for + your new page or block. + +2. Add a label field: + Under the "Fields" section, add a field you would like to be used as labels + along one axis of the chart (or slices of the pie). If you are visualizing + an Event content type, you might select `title`. + +3. Add a data field or fields: + Now add a second field that will be used to determine the data values. If you + are visualizing an Event content type, this field might be + `field_number_attendees`. The label you give this field will be used in the + chart's legend to represent this series. Do this again for each different + quantity you would like to chart. Note that some chart types (e.g. "pie") + only support a single data column. + +4. Configure the chart display: + Click on the "Settings" link in the "Format" section to configure the chart. + Select your chart library, type, the label field, and the data provider. + There are many other options to customize your chart. Some options may not + be available to all chart types and will be adjusted based on the type + selected. + +5. Save your view. + +As described in the steps above - if you have a field representing a number, you +do not necessarily need to aggregate your view. If the content you want to +visualize does not include a numeric field, you may need to aggregate your view +and use the `count` function to generate a number for your data field. + +**Tip:** You may find it easier to start with a "Table" display and convert it +to a chart display after setting up the data. It can be easier to visualize +what the result of the chart will be if it's been laid out in a table first. + +### Creating Combo Charts + +If you are creating your chart in Views and want to a chart that includes +multiple chart types (such as columns and a line - aka a "combo" chart), add a +new display of type "Chart attachment". Use the middle column of this display +to attach the "Chart attachment" to a parent display (this is required). In +this section, you can also instruct the "Chart attachment" to inherit exposed +or contextual filters and if it should use the primary y-axis or a secondary +y-axis. You still need to configure the "Settings" in the "Format" section. + +## Creating Charts in the UI with a Chart Field + +If you want a chart in your entity (e.g. "node"), but don't need its data to be +dynamic (like Views could generate), you can use a Chart field. Add this field +to your bundle (e.g. "content type") via the "Manage Fields" tab - like you +would any other field. When you add a new entity, you can select the charting +library, chart type, and other configurations. You can add data using a CSV or +by manually adding it into the table input. The chart will show on your entity +after saving. + +## Creating Charts in the UI with a Chart Block + +If you are using Layout Builder or want to place a chart on several pages, and +the chart does not need dynamic data (like Views could generate), you can use a +Chart Block. When you add a Chart Block, you can select the charting library, +chart type, and other configurations. You can add data using a CSV or by +manually adding it into the table input. + +## Creating Charts Using the API + +Charts are elements that can be rendered using a Drupal render array. Please +refer to the `charts.api.php` file and also look at the included submodule, +`charts_api_examples` (specifically the controller, which has many examples). + +## Debugging Your Charts + +There is a checkbox in the "Advanced" tab of the config form +(/admin/config/content/charts/advanced) that enabled debugging. Checking this +box will enable a collapsible "details" element that includes the JSON +object generated by the Charts module - this can often be added directly +into a code playground provided by the charting library. If you are +implementing your own Charts submodule, you can add a line like this into +your charts_MYMODULE.js file (it may need modification): +``` +if (element.nextElementSibling && +element.nextElementSibling.hasAttribute('data-charts-debug-container')) { + element.nextElementSibling.querySelector('code').innerText = JSON.stringify( + config, + null, + ' ' + ); +} +``` + +## Support + +For bug reports and feature requests please use the Drupal.org issue tracker: +https://www.drupal.org/project/issues/charts. + +We welcome your support in improving code documentation, tests, and providing +example use-cases not addressed by the existing module. + +If you are interested in creating your own submodule for a library not +currently supported, please contact +[@andileco](https://www.drupal.org/u/andileco). diff --git a/web/modules/charts/charts.api.php b/web/modules/charts/charts.api.php new file mode 100644 index 00000000..c2158211 --- /dev/null +++ b/web/modules/charts/charts.api.php @@ -0,0 +1,174 @@ + 'chart', + * '#chart_type' => 'column', + * 'series' => [ + * '#type' => 'chart_data', + * '#title' => t('Responses'), + * '#data' => [60, 40], + * ], + * 'xaxis' => [ + * '#type' => 'chart_xaxis', + * '#labels' => [t('Yes'), t('No')], + * ] + * ]; + * @endcode + * + * On charts that have multiple axes, you'll need to add individual sub-elements + * for each series of data. If you desire, you may also customize the axes by + * providing an axis element too. + * + * @code + * $chart = [ + * '#type' => 'chart', + * '#chart_type' => 'column', + * ]; + * $chart['series'] = [ + * '#type' => 'chart_data', + * '#title' => t('Responses'), + * '#data' => [10, 20, 30], + * ]; + * $chart['xaxis'] = [ + * '#type' => 'chart_xaxis', + * '#title' => t('Month'), + * '#labels' => [t('Jan'), t('Feb'), t('Mar')], + * ]; + * @endcode + * + * Once you have generated a chart object, you can run + * \Drupal::service('renderer')->render() on it to turn it into HTML: + * + * @code + * $output = \Drupal::service('renderer')->render($chart); + * @endcode + * + * There are many, many properties available for the four chart types (chart, + * chart_data, chart_xaxis, and chart_yaxis). For a full list, see the + * charts_element_info() function. + * + * This module also includes a number of examples for reference in the + * charts_api_example module, and displayed at /charts/example/display. + * + * @see Drupal\charts\Element folder + */ + +/** + * Alter an individual chart before it is printed. + * + * @param array $element + * The chart renderable. Passed in by reference. + * @param string $chart_id + * The chart identifier, pulled from the $chart['#chart_id'] property + * (if any). Not all charts have a chart identifier. + */ +function hook_chart_alter(array &$element, $chart_id) { + if ($chart_id === 'view_name__display_name') { + // Individual properties may be modified. + $element['#title_font_size'] = 20; + } +} + +/** + * Alter an individual chart before it's rendered. + * + * Same as hook_chart_alter(), only including the $chart_id in the function + * name instead of being passed in as an argument. + * + * @see hook_chart_alter() + */ +function hook_chart_CHART_ID_alter(array &$element) { +} + +/** + * Alter an individual chart's raw library representation. + * + * This hook is called AFTER hook_chart_alter(), after Charts module has + * converted the renderable into the chart definition that will be used by the + * library. Note that the structure of $definition will differ based on the + * charting library used. Switching charting libraries may cause your code + * to break when using this hook. + * + * Even though this hook may be fragile, it may provide developers with access + * to library-specific functionality. + * + * @param array $definition + * The chart definition to be modified. The raw values are passed directly to + * the charting library. + * @param array $element + * The chart renderable. This may be used for reference (or read to add + * support for new properties), but any changes to this variable will not + * have an effect on output. + * @param string $chart_id + * The chart ID, derived from the $chart['#chart_id'] property. Note that not + * all charts may have a $chart_id. + */ +function hook_chart_definition_alter(array &$definition, array $element, $chart_id) { + if ($element['#chart_library'] === 'google') { + $definition['options']['titleTextStyle']['fontSize'] = 20; + } + elseif ($element['#chart_library'] === 'highcharts') { + $definition['title']['style']['fontSize'] = 20; + // Add a caption based on a field on your node containing a Chart field. + $node = $element['#entity']; + if ($node instanceof \Drupal\node\NodeInterface && $node->hasField('field_caption')) { + $definition['caption']['text'] = $node->field_caption->value; + } + } +} + +/** + * Alter an individual chart before it's rendered. + * + * Same as hook_chart_definition_alter(), only including the $chart_id in the + * function name instead of being passed in as an argument. + * + * @see hook_chart_definition_alter() + */ +function hook_chart_definition_CHART_ID_alter(array &$definition, array $element, $chart_id) { +} + +/** + * Alter the list of supported chart types for a chart plugin. + * + * To use this hook, first define the chart types you want to add in a file + * named .charts_types.yml to your module's root directory. For example, + * if your module is named "charts_foo", the file should be named + * "charts_foo.charts_types.yml". + * + * The contents of this file will look something like: + * + * @code + * candlestick: + * label: 'Candlestick' + * axis: 'xy' + * axis_inverted: false + * stacking: false + * @endcode + * + * @see charts.charts_types.yml + * + * With that file in place, you can now implement this hook. See below for an + * example adding the 'candlestick' chart type to the 'highcharts' chart plugin. + * + * @param array $types + * An array of supported chart types. + * @param string $chart_plugin_id + * The chart plugin ID. + */ +function hook_charts_plugin_supported_chart_types_alter(array &$types, string $chart_plugin_id) { + if ($chart_plugin_id === 'highcharts') { + $types[] = 'candlestick'; + } +} diff --git a/web/modules/charts/charts.charts_types.yml b/web/modules/charts/charts.charts_types.yml new file mode 100755 index 00000000..c0554483 --- /dev/null +++ b/web/modules/charts/charts.charts_types.yml @@ -0,0 +1,50 @@ +area: + label: 'Area' + axis: 'xy' + axis_inverted: false + stacking: true +bar: + label: 'Bar' + axis: 'xy' + axis_inverted: true + stacking: true +bubble: + label: 'Bubble' + axis: 'xy' + axis_inverted: false + stacking: false +column: + label: 'Column' + axis: 'xy' + axis_inverted: false + stacking: true +donut: + label: 'Donut' + axis: 'y_only' + axis_inverted: false + stacking: false +gauge: + label: 'Gauge' + axis: 'y_only' + axis_inverted: false + stacking: false +line: + label: 'Line' + axis: 'xy' + axis_inverted: false + stacking: false +pie: + label: 'Pie' + axis: 'y_only' + axis_inverted: false + stacking: false +scatter: + label: 'Scatter' + axis: 'xy' + axis_inverted: false + stacking: false +spline: + label: 'Spline' + axis: 'xy' + axis_inverted: false + stacking: false diff --git a/web/modules/charts/charts.config_translation.yml b/web/modules/charts/charts.config_translation.yml new file mode 100644 index 00000000..1b4e0325 --- /dev/null +++ b/web/modules/charts/charts.config_translation.yml @@ -0,0 +1,5 @@ +charts.settings: + title: 'Charts settings' + base_route_name: charts.settings + names: + - charts.settings diff --git a/web/modules/charts/charts.info.yml b/web/modules/charts/charts.info.yml new file mode 100644 index 00000000..e1b2d1b3 --- /dev/null +++ b/web/modules/charts/charts.info.yml @@ -0,0 +1,11 @@ +name: Charts +type: module +description: 'A charting API for Drupal that provides chart elements and integration with Views.' +package: Charts +core_version_requirement: ^10.3 || ^11 +configure: charts.settings + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/charts.install b/web/modules/charts/charts.install new file mode 100755 index 00000000..bd42a903 --- /dev/null +++ b/web/modules/charts/charts.install @@ -0,0 +1,15 @@ + [], + ]; + $data['charts_fields']['field_charts_fields_scatter'] = [ + 'title' => t('Scatter Field'), + 'help' => t('Use this field for your data field in a scatter plot.'), + 'field' => [ + 'id' => 'field_charts_fields_scatter', + ], + ]; + $data['charts_fields']['field_charts_fields_bubble'] = [ + 'title' => t('Bubble Field'), + 'help' => t('Use this field for your data field in a bubble chart.'), + 'field' => [ + 'id' => 'field_charts_fields_bubble', + ], + ]; + $data['charts_fields']['field_exposed_chart_type'] = [ + 'title' => t('Exposed Chart Type'), + 'help' => t('Use this field for exposing chart type.'), + 'field' => [ + 'id' => 'field_exposed_chart_type', + ], + ]; + + return $data; +} + +/** + * Implements hook_theme(). + */ +function charts_theme($existing, $type, $theme, $path) { + return [ + 'charts_chart' => [ + 'render element' => 'element', + ], + ]; +} + +/** + * Implements hook_preprocess_HOOK(). + */ +function template_preprocess_charts_chart(&$variables) { + $element = $variables['element']; + + $attributes = $element['#attributes']; + $attributes['id'] = $element['#id']; + $attributes['class'][] = 'chart'; + + $variables['content'] = [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => $attributes, + '#value' => $element['#chart'] ?? '', + ]; + $variables['content_prefix'] = $element['#content_prefix']; + $variables['content_suffix'] = $element['#content_suffix']; + $config = \Drupal::config('charts.settings'); + $variables['debug'] = []; + if (!empty($config->get('advanced.debug'))) { + $variables['debug'] = [ + '#type' => 'details', + '#title' => t('Chart JSON'), + '#open' => FALSE, + '#attributes' => [ + 'data-charts-debug-container' => TRUE, + ], + '#collapsible' => TRUE, + 'json' => [ + '#prefix' => '
',
+        '#type' => 'html_tag',
+        '#tag' => 'code',
+        '#attributes' => [
+          'class' => ['language-json'],
+        ],
+        '#value' => t('If this is blank, your library will need to implement code in your JavaScript. Please see the README.md.'),
+        '#suffix' => '
', + ], + ]; + } +} + +/** + * Implements hook_views_pre_view(). + */ +function charts_views_pre_view(ViewExecutable $view, $display_id, array &$args) { + $hasFields = array_key_exists('fields', $view->display_handler->options); + if ($hasFields) { + $fields = $view->display_handler->getOption('fields'); + $hasViewsFieldsOnOffHandler = FALSE; + foreach ($fields as $field) { + if (array_key_exists('plugin_id', $field)) { + + if ($field['plugin_id'] == 'field_exposed_chart_type') { + $hasViewsFieldsOnOffHandler = TRUE; + } + } + } + if ($hasViewsFieldsOnOffHandler) { + // Grab the type that has been submitted. + $params = \Drupal::request()->query->all(); + // This is for a GET request. + // If the view is submitted through AJAX, like in view preview, it will be + // a POST request. Merge the parameter arrays and we will get our values. + $postParams = \Drupal::request()->request->all(); + $params = array_merge($params, $postParams); + foreach ($params as $key => $value) { + if (strpos($key, 'ct') === 0) { + $view->storage->set('exposed_chart_type', $value); + } + } + $view->element['#cache']['contexts'][] = 'url'; + } + } +} + +/** + * Implements hook_library_info_alter(). + * + * Adapted from webform_library_info_alter() to add CDN support. + */ +function charts_library_info_alter(&$libraries, $extension) { + // Only process for charts_* modules. + if (strpos($extension, 'charts_') !== 0) { + return; + } + + // Only try to apply cdn changes if the cdn requirements is on. + $config = \Drupal::config('charts.settings'); + if (!$config->get('advanced.requirements.cdn')) { + return; + } + + foreach ($libraries as &$library) { + if (isset($library['cdn']) && is_array($library['cdn'])) { + _charts_library_info_alter_recursive($library, $library['cdn']); + } + } +} + +/** + * Recursive through a charts library. + * + * @param array $library + * A library defined in a chart module. + * @param array $cdn + * A associative array of library paths mapped to CDN URL. + * + * @note copied from _webform_library_info_alter_recursive(). + */ +function _charts_library_info_alter_recursive(array &$library, array $cdn) { + foreach ($library as $key => &$value) { + // CSS and JS files and listed in associative arrays keyed via string. + if (!is_string($key) || !is_array($value)) { + continue; + } + + // Ignore the CDN's associative array. + if ($key === 'cdn') { + continue; + } + + // Replace the CDN sources (i.e. /library/*) with the CDN URL destination + // (https://cdnjs.cloudflare.com/ajax/libs/*). + foreach ($cdn as $source => $destination) { + if (_charts_library_check_source_exists($source)) { + continue; + } + if (strpos($key, $source) === 0) { + $uri = str_replace($source, $destination, $key); + $library[$uri] = $value; + $library[$uri]['type'] = 'external'; + unset($library[$key]); + break; + } + } + + // Recurse downward to find nested libraries. + _charts_library_info_alter_recursive($value, $cdn); + } +} + +/** + * Checks whether a library source directory or file exists locally. + * + * @param string $source + * The source directory or file. + * + * @return bool + * True when the file exist, FALSE otherwise. + */ +function _charts_library_check_source_exists(string $source) { + $source = rtrim($source, '/'); + + $container = \Drupal::getContainer(); + $app_root = $container->getParameter('app.root'); + $site_path = $container->getParameter('site.path'); + + // The following logic is taken from libraries_get_libraries() + $search_dir = []; + $search_dir[] = $app_root; + // Similar to 'modules' and 'themes' directories inside an installation + // profile, installation profiles may want to place libraries into a + // 'libraries' directory. + $search_dir[] = 'profiles/' . \Drupal::installProfile(); + // Also search sites//*. + $search_dir[] = $site_path; + + foreach ($search_dir as $dir) { + if (file_exists($dir . $source) || file_exists($dir . $source)) { + return TRUE; + } + } + return FALSE; +} diff --git a/web/modules/charts/charts.permissions.yml b/web/modules/charts/charts.permissions.yml new file mode 100755 index 00000000..9c2bd406 --- /dev/null +++ b/web/modules/charts/charts.permissions.yml @@ -0,0 +1,4 @@ +'access all charts': + title: 'Administer Charts' + description: 'This permission needs to be fleshed out.' + 'restrict access': true diff --git a/web/modules/charts/charts.post_update.php b/web/modules/charts/charts.post_update.php new file mode 100644 index 00000000..7801a0c4 --- /dev/null +++ b/web/modules/charts/charts.post_update.php @@ -0,0 +1,221 @@ +getEditable('charts.settings'); + + if ($config) { + $config->set('advanced', ['requirements' => ['cdn' => TRUE]]); + $config->save(); + return 'Successfully initialized the advanced.requirements.cdn option.'; + } + return 'No initialization was done. Please check your current config.'; +} + +/** + * Update the existing default config display colors to increase from 10 to 25. + */ +function charts_post_update_existing_default_colors_to_twenty_five(&$sandbox) { + $config = \Drupal::service('config.factory')->getEditable('charts.settings'); + $existing_colors = $config->get('charts_default_settings.display.colors'); + + if (is_countable($existing_colors) && count($existing_colors) === 10) { + $path = \Drupal::service('extension.list.module')->getPath('charts'); + $default_install_settings_file = $path . '/config/install/charts.settings.yml'; + if (!file_exists($default_install_settings_file)) { + return 'Failed to update the default colors to 25 because we could not load the charts settings.'; + } + + $default_install_settings = Yaml::decode(file_get_contents($default_install_settings_file)); + $install_colors = $default_install_settings['charts_default_settings']['display']['colors']; + // We only want to add the last 15 colors to make it 25. + $colors = array_merge($existing_colors, array_slice($install_colors, -15)); + $config->set('charts_default_settings.display.colors', $colors); + $config->save(); + return 'Successfully increased the number of default colors to 25.'; + } + return 'We were unable to load existing colors in order to increased them to 25.'; +} + +/** + * Initialize the config value for the debug setting. + */ +function charts_post_update_initialize_debug_option(&$sandbox) { + $config = \Drupal::service('config.factory') + ->getEditable('charts.settings'); + + if ($config) { + $config->set('advanced.debug', FALSE); + $config->save(); + return 'Successfully initialized the advanced.debug option.'; + } + return 'No initialization was done. Please check your current config.'; +} + +/** + * Migrate library default settings to library_config key. + */ +function charts_post_update_migrate_library_default_settings_to_library_config(&$sandbox) { + /** @var \Drupal\Core\Config\ConfigFactoryInterface $config_factory */ + $config_factory = \Drupal::service('config.factory'); + $config = $config_factory->getEditable('charts.settings'); + + if ($config) { + $default_settings = $config->get('charts_default_settings'); + $library_id = $default_settings['library'] ?? ''; + $old_key = $library_id ? 'charts_default_settings.' . $library_id . '_settings' : ''; + if ($old_key && empty($default_settings['display']) && $library_config = $config->get($old_key)) { + $config->set('charts_default_settings.library_config', $library_config); + $config->clear($old_key); + $config->save(); + return 'Library default settings have been successfully migrated to library_config key.'; + } + } + return 'No migration was done'; +} + +/** + * Clear library_config set on the wrong place. See drupal.org/i/3310145. + */ +function charts_post_update_clear_library_config_key_added_on_wrong_place(&$sandbox) { + /** @var \Drupal\Core\Config\ConfigFactoryInterface $config_factory */ + $config_factory = \Drupal::service('config.factory'); + $config = $config_factory->getEditable('charts.settings'); + if ($config && $config->get('library_config')) { + // Not to be confused with "charts_default_settings.library_config" which is + // the proper key. + $config->clear('library_config'); + $config->save(); + return 'Successfully cleared the library_config key.'; + } + return 'The library_config key was not cleared, probably because it was not set.'; +} + +/** + * Updates charts config and views with charts style of version 3 to new config. + */ +function charts_post_update_00_update_charts_version_3_to_latest_settings_structure(&$sandbox) { + /** @var \Drupal\Core\Config\ConfigFactoryInterface $config_factory */ + $config_factory = \Drupal::service('config.factory'); + $config = $config_factory->getEditable('charts.settings'); + $default_settings = $config ? $config->get('charts_default_settings') : []; + + /** @var \Drupal\charts\ConfigUpdater $config_updater */ + $config_updater = \Drupal::classResolver(ConfigUpdater::class); + if ($default_settings && !isset($default_settings['display'])) { + $results = []; + + $config->set('charts_default_settings', $config_updater->transformVersion3SettingsToNew($default_settings)); + $config->save(); + $results[] = 'Default Config: Migration to the new structure successfully done.'; + + $results[] = $config_updater->updateExistingViewsVersion3ToNewSettings(); + return implode(' -- ', $results); + } + elseif (!$default_settings || !$config->getRawData()) { + $config->setData($config_updater->initializedCurrentDefaultSettings()); + $config->save(); + + $results = []; + $results[] = 'Default Config: No config were found and the default one was saved.'; + $results[] = $config_updater->updateExistingViewsVersion3ToNewSettings(); + return implode(' -- ', $results); + } + return 'No update of the settings structure was done.'; +} + +/** + * Re-save views that are using the chart style to recalculate dependencies. + */ +function charts_post_update_resave_views_to_account_calculated_library_dependencies(&$sandbox) { + $view_storage = \Drupal::entityTypeManager()->getStorage('view'); + $view_ids = $view_storage->getQuery() + ->accessCheck(FALSE) + ->condition('display.*.display_options.style.type', 'chart', '=') + ->execute(); + $labels = []; + if ($view_ids) { + foreach ($view_ids as $view_id) { + /** @var \Drupal\views\ViewEntityInterface $view */ + $view = $view_storage->load($view_id); + // Re-save the view to update its dependencies. + $view->save(); + $labels[] = $view->label(); + } + } + + if ($labels) { + return t('The following views have been re-saved to re-calculate their dependencies: @views', [ + '@views' => implode(',', $labels), + ]); + } + return t('This site did not have any chart related views.'); +} + +/** + * Setting charts config module dependencies based on the selected library. + */ +function charts_post_update_setting_charts_config_module_dependencies(&$sandbox) { + $config = \Drupal::service('config.factory') + ->getEditable('charts.settings'); + $settings = $config->get('charts_default_settings'); + $library = $settings['library'] ?? ''; + $type = $settings['type'] ?? ''; + if ($library || $type) { + /** @var \Drupal\charts\EventSubscriber\ConfigImportSubscriber $config_import_subscriber */ + $config_import_subscriber = \Drupal::service('charts.config_import_subscriber'); + $config->set('dependencies', $config_import_subscriber->calculateDependencies($library, $type)) + ->save(); + return 'Updated chart settings dependencies'; + } + return 'The default config for charts did not have a library and type set. So no dependencies were set.'; +} + +/** + * Move color changer from fields to display. + */ +function charts_post_update_move_views_style_color_changer_from_fields_to_display(&$sandbox) { + $view_storage = \Drupal::entityTypeManager()->getStorage('view'); + $view_ids = $view_storage->getQuery() + ->accessCheck(FALSE) + ->condition('display.*.display_options.style.type', 'chart', '=') + ->execute(); + $labels = []; + if ($view_ids) { + foreach ($view_ids as $view_id) { + /** @var \Drupal\views\ViewEntityInterface $view */ + $view = $view_storage->load($view_id); + $changed = FALSE; + foreach (array_keys($view->get('display')) as $display_id) { + $display = &$view->getDisplay($display_id); + if (isset($display['display_options']['style']['options']['chart_settings']['fields']['color_changer'])) { + $changed = TRUE; + $display['display_options']['style']['options']['chart_settings']['display']['color_changer'] = !empty($display['display_options']['style']['options']['chart_settings']['fields']['color_changer']); + unset($display['display_options']['style']['options']['chart_settings']['fields']['color_changer']); + } + } + if ($changed) { + $view->save(); + $labels[] = $view->label(); + } + } + } + + if ($labels) { + return t('The following views have been updated to move the color changer from fields to display: @views', [ + '@views' => implode(',', $labels), + ]); + } + return t('This site did not have any chart related views.'); +} diff --git a/web/modules/charts/charts.routing.yml b/web/modules/charts/charts.routing.yml new file mode 100755 index 00000000..84e9731e --- /dev/null +++ b/web/modules/charts/charts.routing.yml @@ -0,0 +1,15 @@ +charts.settings: + path: /admin/config/content/charts + defaults: + _form: Drupal\charts\Form\ChartsConfigForm + _title: 'Chart configuration' + requirements: + _permission: 'administer site configuration' + +charts.settings.advanced: + path: /admin/config/content/charts/advanced + defaults: + _form: Drupal\charts\Form\ChartsConfigAdvancedForm + _title: 'Chart advanced configuration form' + requirements: + _permission: 'administer site configuration' diff --git a/web/modules/charts/charts.services.yml b/web/modules/charts/charts.services.yml new file mode 100755 index 00000000..63578776 --- /dev/null +++ b/web/modules/charts/charts.services.yml @@ -0,0 +1,21 @@ +services: + plugin.manager.charts: + class: Drupal\charts\ChartManager + parent: default_plugin_manager + + plugin.manager.charts_type: + class: Drupal\charts\TypeManager + arguments: ['@module_handler', '@cache.discovery', '@event_dispatcher'] + + charts.plugins_uninstall_validator: + class: Drupal\charts\PluginsUninstallValidator + tags: + - { name: module_install.uninstall_validator } + arguments: ['@config.factory'] + lazy: true + + charts.config_import_subscriber: + class: Drupal\charts\EventSubscriber\ConfigImportSubscriber + arguments: ['@plugin.manager.charts', '@plugin.manager.charts_type'] + tags: + - { name: event_subscriber } diff --git a/web/modules/charts/charts.settings.yml b/web/modules/charts/charts.settings.yml new file mode 100755 index 00000000..e69de29b diff --git a/web/modules/charts/config/install/charts.settings.yml b/web/modules/charts/config/install/charts.settings.yml new file mode 100644 index 00000000..d7746389 --- /dev/null +++ b/web/modules/charts/config/install/charts.settings.yml @@ -0,0 +1,73 @@ +dependencies: [] +charts_default_settings: + library: '' + type: line + display: + title: '' + subtitle: '' + colors: + - '#006fb0' + - '#f07c33' + - '#342e9c' + - '#579b17' + - '#3f067a' + - '#cbde67' + - '#7643b6' + - '#738d00' + - '#c157c7' + - '#02dab1' + - '#ed56b4' + - '#d8d981' + - '#004695' + - '#736000' + - '#a5a5ff' + - '#833a00' + - '#ff9ee9' + - '#684507' + - '#fe4f85' + - '#5d0011' + - '#ffa67b' + - '#88005c' + - '#ff9b8f' + - '#85000f' + - '#ff7581' + color_changer: false + title_position: out + tooltips: true + tooltips_use_html: false + data_markers: false + data_labels: false + legend: false + legend_position: right + background: '' + three_dimensional: 0 + polar: 0 + dimensions: + width: '' + width_units: '%' + height: '' + height_units: '' + gauge: + max: '100' + min: '0' + green_from: '85' + green_to: '100' + yellow_from: '50' + yellow_to: '85' + red_from: '0' + red_to: '50' + xaxis: + title: '' + labels_rotation: '0' + yaxis: + title: '' + min: '' + max: '' + prefix: '' + suffix: '' + decimal_count: '' + labels_rotation: '0' +advanced: + debug: false + requirements: + cdn: true diff --git a/web/modules/charts/config/schema/charts.data_types.schema.yml b/web/modules/charts/config/schema/charts.data_types.schema.yml new file mode 100644 index 00000000..d4368f5d --- /dev/null +++ b/web/modules/charts/config/schema/charts.data_types.schema.yml @@ -0,0 +1,216 @@ +# Basic data types for charts. + +charts_dimensions: + type: mapping + mapping: + width: + type: string + label: 'Width' + width_units: + type: string + label: 'Width units' + height: + type: string + label: 'Height' + height_units: + type: string + label: 'Height units' + +charts_gauge: + type: mapping + mapping: + max: + type: string + label: 'Gauge maximum value' + min: + type: string + label: 'Gauge minimum value' + green_from: + type: string + label: 'Green minimum value' + green_to: + type: string + label: 'Green maximum value' + yellow_from: + type: string + label: 'Yellow minimum value' + yellow_to: + type: string + label: 'Yellow maximum value' + red_from: + type: string + label: 'Red minimum value' + red_to: + type: string + label: 'Red maximum value' + +charts_display: + type: mapping + mapping: + title: + type: label + label: 'Chart title' + title_position: + type: string + label: 'Title position' + subtitle: + type: string + label: 'Chart subtitle' + data_labels: + type: boolean + label: 'Enable data labels' + data_markers: + type: boolean + label: 'Enable data markers' + legend: + type: boolean + label: 'Legend' + legend_position: + type: string + label: 'Legend position' + background: + type: string + label: 'Background' + three_dimensional: + type: integer + label: 'Make chart three-dimensional (3D)' + colors: + type: sequence + label: 'Chart colors' + sequence: + type: string + label: 'Hexadecimal color' + polar: + type: integer + label: 'Transform cartesian charts into the polar coordinate system' + tooltips: + type: boolean + label: 'Tooltips' + tooltips_use_html: + type: boolean + label: 'Tooltips Use HTML' + dimensions: + type: charts_dimensions + label: 'Dimensions' + gauge: + type: charts_gauge + label: 'Gauge' + color_changer: + type: boolean + label: 'Enable color changer widget' + +charts_xaxis: + type: mapping + mapping: + title: + type: label + label: 'X axis title' + labels_rotation: + type: string + label: 'X axis labels rotation' + +charts_yaxis: + type: mapping + mapping: + title: + type: label + label: 'Y axis title' + min: + type: string + label: 'Y axis min value' + max: + type: string + label: 'Y axis max value' + prefix: + type: label + label: 'Y axis prefix' + suffix: + type: label + label: 'Y axis suffix' + decimal_count: + type: string + label: 'Y axis decimal count' + labels_rotation: + type: string + label: 'Y axis labels rotation' + +charts_views_field_data_provider: + type: mapping + mapping: + enabled: + type: boolean + label: 'Enabled' + color: + type: string + label: Color + weight: + type: integer + label: 'Weight' + +charts_views_field_entity_grouping_selected_method_colors: + type: mapping + mapping: + color: + type: string + label: Color + +charts_config: + type: mapping + mapping: + library: + type: string + label: 'Charting library' + type: + type: string + label: 'Chart type' + fields: + type: mapping + label: 'Charts fields' + mapping: + label: + type: string + label: 'Label field' + stacking: + type: boolean + label: Stacking + data_providers: + type: sequence + label: 'Data providers' + sequence: + type: charts_views_field_data_provider + entity_grouping: + type: mapping + label: 'Entity grouping settings' + mapping: + color_selection_method: + type: string + label: 'Color selection method' + selected_method: + type: mapping + label: 'Selected method' + mapping: + color_field_name: + type: string + label: 'Color field name' + colors: + type: sequence + label: 'Colors' + sequence: + type: charts_views_field_entity_grouping_selected_method_colors + display: + type: charts_display + label: 'Display' + xaxis: + type: charts_yaxis + label: 'X axis Display' + yaxis: + type: charts_yaxis + label: 'Y axis Display' + library_config: + type: charts.settings.library_plugin.[%parent.library] + +charts.settings.library_plugin.*: + type: charts_settings_library_plugin + +charts_settings_library_plugin: + type: mapping diff --git a/web/modules/charts/config/schema/charts.settings.schema.yml b/web/modules/charts/config/schema/charts.settings.schema.yml new file mode 100755 index 00000000..8f1605d2 --- /dev/null +++ b/web/modules/charts/config/schema/charts.settings.schema.yml @@ -0,0 +1,24 @@ +charts.settings: + type: config_object + label: 'Charts settings' + mapping: + charts_default_settings: + type: charts_config + label: 'Charts default settings' + advanced: + type: mapping + label: Advanced + mapping: + debug: + type: boolean + label: 'Enable Charts Debug' + requirements: + type: mapping + label: Requirements + mapping: + cdn: + type: boolean + label: 'Check if CDN is being used for external libraries' + dependencies: + type: config_dependencies + label: 'Dependencies' diff --git a/web/modules/charts/config/schema/charts.views.schema.yml b/web/modules/charts/config/schema/charts.views.schema.yml new file mode 100755 index 00000000..8ac085e2 --- /dev/null +++ b/web/modules/charts/config/schema/charts.views.schema.yml @@ -0,0 +1,47 @@ +views.style.chart: + type: views_style + label: 'Chart' + mapping: + chart_settings: + type: charts_config + label: 'Settings' + +views.display.chart_extension: + type: views_display + label: 'Attachment display options' + +views.field.field_exposed_chart_type: + type: views_field + label: 'Exposed Chart Type' + mapping: + chart_types: + type: sequence + label: 'Chart Type Options' + exposed_select_type: + type: string + label: 'Exposed Selection Type' + +views.field.field_charts_fields_bubble: + type: views_field + label: 'Bubble Field' + mapping: + x_axis: + type: string + label: 'X Axis Field' + y_axis: + type: string + label: 'Y Axis Field' + z_axis: + type: string + label: 'Z Axis Field' + +views.field.field_charts_fields_scatter: + type: views_field + label: 'Scatter Field' + mapping: + x_axis: + type: string + label: 'X Axis Field' + y_axis: + type: string + label: 'Y Axis Field' diff --git a/web/modules/charts/js/charts.es6.js b/web/modules/charts/js/charts.es6.js new file mode 100644 index 00000000..c49277a9 --- /dev/null +++ b/web/modules/charts/js/charts.es6.js @@ -0,0 +1,46 @@ +/** + * @file + * Charts API. + */ +((Drupal) => { + + Drupal.Charts = Drupal.Charts || {}; + Drupal.Charts.Configs = Drupal.Charts.Configs || []; + + /** + * @typedef {class} Drupal.Charts.Contents + */ + + Drupal.Charts.Contents = class { + constructor() { + const chartsElements = document.querySelectorAll('[data-chart]'); + chartsElements.forEach(function (el) { + const id = el.getAttribute('id'); + Drupal.Charts.Configs[id] = JSON.parse(el.getAttribute('data-chart')); + Drupal.Charts.Configs[id].drupalChartDivElement = el; + Drupal.Charts.Configs[id].drupalChartDivId = id; + }); + } + + initialize(id) { + const event = new CustomEvent('drupalChartsConfigsInitialization', { + detail: Drupal.Charts.Configs[id] + }); + Drupal.Charts.Configs[id].drupalChartDivElement.dispatchEvent(event); + } + + static update(id, data) { + if (Drupal.Charts.Configs.hasOwnProperty(id)) { + Drupal.Charts.Configs[id] = data; + } + } + + getData(id) { + if (Drupal.Charts.Configs.hasOwnProperty(id)) { + this.initialize(id); + return Drupal.Charts.Configs[id]; + } + return {}; + } + }; +})(Drupal); diff --git a/web/modules/charts/js/charts.js b/web/modules/charts/js/charts.js new file mode 100644 index 00000000..e27fbd24 --- /dev/null +++ b/web/modules/charts/js/charts.js @@ -0,0 +1,45 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(Drupal => { + Drupal.Charts = Drupal.Charts || {}; + Drupal.Charts.Configs = Drupal.Charts.Configs || []; + Drupal.Charts.Contents = class { + constructor() { + const charts_elements = document.querySelectorAll('[data-chart]'); + charts_elements.forEach(function (el) { + const id = el.getAttribute('id'); + Drupal.Charts.Configs[id] = JSON.parse(el.getAttribute('data-chart')); + Drupal.Charts.Configs[id].drupalChartDivElement = el; + Drupal.Charts.Configs[id].drupalChartDivId = id; + }); + } + + initialize(id) { + const event = new CustomEvent('drupalChartsConfigsInitialization', { + detail: Drupal.Charts.Configs[id] + }); + Drupal.Charts.Configs[id].drupalChartDivElement.dispatchEvent(event); + } + + static update(id, data) { + if (Drupal.Charts.Configs.hasOwnProperty(id)) { + Drupal.Charts.Configs[id] = data; + } + } + + getData(id) { + if (Drupal.Charts.Configs.hasOwnProperty(id)) { + this.initialize(id); + return Drupal.Charts.Configs[id]; + } + + return {}; + } + + }; +})(Drupal); diff --git a/web/modules/charts/modules/charts_api_example/README.md b/web/modules/charts/modules/charts_api_example/README.md new file mode 100755 index 00000000..cb5e7715 --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/README.md @@ -0,0 +1,15 @@ +#Charts API Example + +This module shows you how to create charts using render arrays. There are +numerous examples in the `ChartsApiExample.php` controller file. In the +`charts_api_example.module` file, you can also see how some hooks described in +`charts.api.php` can be used. One hook overrides a chart using PHP. Another +overrides with JavaScript - this is necessary in some cases when the charting +library API is expecting a function rather than a string. You can read more +about overriding with JavaScript here: +https://www.drupal.org/project/charts/issues/3197574#comment-13999765 + +To see the examples in your site, navigate to: `/charts/example/display` + +Settings for these charts are set in the Charts configuration page +(`/admin/config/content/charts`). diff --git a/web/modules/charts/modules/charts_api_example/charts_api_example.info.yml b/web/modules/charts/modules/charts_api_example/charts_api_example.info.yml new file mode 100644 index 00000000..e8656606 --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/charts_api_example.info.yml @@ -0,0 +1,12 @@ +name: 'Charts API Example' +type: module +description: 'Demonstrates how to use the Charts module without Views.' +core_version_requirement: ^10.3 || ^11 +package: Examples +dependencies: + - 'charts:charts' + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/modules/charts_api_example/charts_api_example.libraries.yml b/web/modules/charts/modules/charts_api_example/charts_api_example.libraries.yml new file mode 100644 index 00000000..61c7000a --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/charts_api_example.libraries.yml @@ -0,0 +1,6 @@ +charts_api_example: + version: VERSION + js: + js/charts_api_example_js_override.js: { weight: -5 } + dependencies: + - charts/global diff --git a/web/modules/charts/modules/charts_api_example/charts_api_example.links.menu.yml b/web/modules/charts/modules/charts_api_example/charts_api_example.links.menu.yml new file mode 100755 index 00000000..a8e8d468 --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/charts_api_example.links.menu.yml @@ -0,0 +1,5 @@ +charts_api_example.display: + title: 'Charts API Example' + description: 'Displays charts rendered using the Charts API.' + parent: system.admin_config_content + route_name: charts_api_example.display diff --git a/web/modules/charts/modules/charts_api_example/charts_api_example.module b/web/modules/charts/modules/charts_api_example/charts_api_example.module new file mode 100755 index 00000000..1cce3af5 --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/charts_api_example.module @@ -0,0 +1,43 @@ +' . t('About') . ''; + $output .= '

' . t('A simple example on how to interact with the Charts API') . '

'; + break; + } + return $output; +} + +/** + * Implements hook_chart_definition_CHART_ID_alter(). + */ +function charts_api_example_chart_definition_exampleidphp_alter(&$chart) { + // Please note: this will only show when the charts_highcharts module is + // enabled and Highcharts is set as your default library. + $chart['chart']['backgroundColor'] = 'blue'; +} + +/** + * Implements hook_chart_alter(). + */ +function charts_api_example_chart_alter(array &$element, $chart_id) { + // Please note: this will only show when the charts_highcharts module is + // enabled and Highcharts is set as your default library. + if ($chart_id === 'exampleidjschart') { + $element['#attached']['library'][] = 'charts_api_example/charts_api_example'; + } +} diff --git a/web/modules/charts/modules/charts_api_example/charts_api_example.routing.yml b/web/modules/charts/modules/charts_api_example/charts_api_example.routing.yml new file mode 100755 index 00000000..5d0c2c5d --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/charts_api_example.routing.yml @@ -0,0 +1,7 @@ +charts_api_example.display: + path: /charts/example/display + defaults: + _controller: '\Drupal\charts_api_example\Controller\ChartsApiExample::display' + _title: 'How to Use the Charts API' + requirements: + _permission: 'access content' diff --git a/web/modules/charts/modules/charts_api_example/composer.json b/web/modules/charts/modules/charts_api_example/composer.json new file mode 100755 index 00000000..4fda5297 --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/composer.json @@ -0,0 +1,16 @@ +{ + "name": "drupal/charts_api_example", + "type": "drupal-module", + "description": "How to interact with the Charts Api", + "keywords": [ + "Drupal" + ], + "license": "GPL-2.0+", + "homepage": "https://www.drupal.org/project/charts_api_example", + "minimum-stability": "dev", + "support": { + "issues": "https://www.drupal.org/project/issues/charts_api_example", + "source": "http://cgit.drupalcode.org/charts_api_example" + }, + "require": {} +} diff --git a/web/modules/charts/modules/charts_api_example/fixtures/charts_api_example_file.csv b/web/modules/charts/modules/charts_api_example/fixtures/charts_api_example_file.csv new file mode 100644 index 00000000..177f6506 --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/fixtures/charts_api_example_file.csv @@ -0,0 +1,463 @@ +Week,6.x-0.x,6.x-1.x,7.x-1.x,7.x-2.x,8.x-1.x,8.x-3.x,8.x-4.x,5.0.x,Total +21-Aug-21,0,71,12,13458,1,4632,6,637,18817 +14-Aug-21,0,77,13,13517,1,4518,9,608,18743 +7-Aug-21,0,73,11,13586,1,4651,6,606,18934 +31-Jul-21,0,76,13,13479,1,4488,7,619,18683 +24-Jul-21,0,70,10,13771,1,4385,7,530,18774 +17-Jul-21,0,73,12,13756,1,4430,8,536,18816 +10-Jul-21,0,77,13,13596,1,4362,8,519,18576 +3-Jul-21,0,75,11,13715,1,4437,8,596,18843 +26-Jun-21,0,73,12,13925,1,4470,9,651,19141 +19-Jun-21,0,80,13,13886,1,4583,7,499,19069 +12-Jun-21,0,79,13,13651,1,4506,9,455,18714 +5-Jun-21,0,84,12,13656,1,4525,8,457,18743 +29-May-21,0,79,15,13935,1,4554,10,438,19032 +22-May-21,0,84,14,13840,1,4511,8,500,18958 +15-May-21,0,80,13,13790,2,4524,11,553,18973 +8-May-21,0,85,13,13748,1,4448,9,357,18661 +1-May-21,0,83,12,13853,0,4371,8,379,18706 +24-Apr-21,0,82,14,13835,1,4431,6,340,18709 +17-Apr-21,0,86,14,14080,0,4455,10,322,18967 +10-Apr-21,0,82,12,13736,0,4326,8,280,18444 +3-Apr-21,0,82,14,13815,0,4347,8,289,18555 +27-Mar-21,0,78,12,13490,1,4212,7,325,18125 +20-Mar-21,0,77,12,13667,0,4389,9,366,18520 +13-Mar-21,0,77,12,13671,0,4293,9,269,18331 +6-Mar-21,0,75,14,13840,0,4361,8,253,18551 +27-Feb-21,0,81,14,14144,1,4413,9,235,18897 +20-Feb-21,0,83,14,14151,0,4451,10,217,18926 +13-Feb-21,0,80,13,14057,0,4294,10,247,18701 +6-Feb-21,0,86,16,14042,0,4271,10,233,18658 +30-Jan-21,0,78,13,14221,0,4330,7,257,18906 +23-Jan-21,0,85,13,14109,0,4283,9,192,18691 +16-Jan-21,0,94,16,14294,0,4227,9,162,18802 +9-Jan-21,0,88,15,14244,0,4133,9,154,18643 +2-Jan-21,0,81,14,14235,0,4123,15,145,18613 +26-Dec-20,0,82,15,13746,0,3721,9,130,17703 +19-Dec-20,0,83,15,13904,0,3704,9,128,17843 +12-Dec-20,0,86,16,14299,0,4007,16,170,18594 +5-Dec-20,0,85,15,14714,0,4047,12,152,19025 +28-Nov-20,0,81,14,14852,0,3970,16,148,19081 +21-Nov-20,0,78,16,14817,0,4347,17,108,19383 +14-Nov-20,0,86,17,14840,0,4567,22,62,19594 +7-Nov-20,0,87,18,14286,0,4110,18,80,18599 +31-Oct-20,0,86,19,14599,0,4100,25,49,18878 +24-Oct-20,0,91,16,14571,0,4157,25,3,18863 +17-Oct-20,0,84,16,14434,0,5116,25,0,19675 +10-Oct-20,0,87,15,14563,0,5102,33,0,19800 +3-Oct-20,0,83,18,14593,0,4059,26,0,18779 +26-Sep-20,0,88,17,14717,0,3971,23,0,18816 +19-Sep-20,0,91,20,14605,0,4064,22,0,18802 +12-Sep-20,0,87,19,14706,0,4865,24,0,19701 +5-Sep-20,0,88,20,14661,0,4641,29,0,19439 +29-Aug-20,0,91,19,14814,0,4557,28,0,19509 +22-Aug-20,0,92,17,14570,0,4572,30,0,19281 +15-Aug-20,0,84,20,14743,0,4615,32,0,19494 +8-Aug-20,0,92,20,14800,0,4627,28,0,19567 +1-Aug-20,0,90,19,14925,0,4433,20,0,19487 +25-Jul-20,0,92,19,15013,0,4533,19,0,19676 +18-Jul-20,0,89,20,14750,0,4532,22,0,19413 +11-Jul-20,0,93,15,14901,0,4594,20,0,19623 +4-Jul-20,0,88,21,15080,0,4467,26,0,19682 +27-Jun-20,0,93,19,15281,0,4679,26,0,20098 +20-Jun-20,0,86,21,15642,0,4835,25,0,20609 +13-Jun-20,0,91,25,15979,0,4613,23,0,20731 +6-Jun-20,0,92,23,15597,0,4564,20,0,20296 +30-May-20,0,86,25,15812,0,4433,17,0,20373 +23-May-20,0,94,27,15830,0,4163,15,0,20129 +16-May-20,0,98,29,15739,0,4169,14,0,20049 +9-May-20,0,98,23,15300,0,4336,12,0,19769 +2-May-20,0,94,27,15370,0,4261,12,0,19764 +25-Apr-20,0,99,28,14711,0,4043,9,0,18890 +18-Apr-20,0,102,25,14857,1,3970,4,0,18959 +11-Apr-20,0,107,27,14884,0,3966,4,0,18988 +4-Apr-20,0,104,25,14829,0,3928,3,0,18889 +28-Mar-20,0,101,25,15302,0,3937,4,0,19369 +21-Mar-20,0,103,27,15217,1,4067,3,0,19418 +14-Mar-20,0,107,26,15136,0,3813,1,0,19083 +7-Mar-20,0,106,26,15202,0,3797,0,0,19131 +29-Feb-20,0,106,29,15269,1,3795,0,0,19200 +22-Feb-20,0,106,30,15222,0,3726,0,0,19084 +15-Feb-20,0,111,29,15289,0,3596,1,0,19026 +8-Feb-20,0,108,28,15328,0,3667,0,0,19131 +1-Feb-20,0,110,28,15523,1,3673,0,0,19335 +25-Jan-20,0,114,25,15279,0,3523,0,0,18941 +18-Jan-20,0,108,29,15289,0,3549,0,0,18975 +11-Jan-20,0,115,29,15540,1,3561,0,0,19246 +4-Jan-20,0,123,26,15612,0,3492,0,0,19253 +28-Dec-19,0,117,30,15151,0,3065,0,0,18363 +21-Dec-19,0,122,26,15067,0,3179,0,0,18394 +14-Dec-19,0,125,27,16078,1,3614,0,0,19845 +7-Dec-19,0,124,25,16029,0,3634,0,0,19812 +30-Nov-19,0,127,30,15804,0,3591,1,0,19553 +23-Nov-19,0,119,28,15640,0,3512,2,0,19301 +16-Nov-19,0,119,31,15755,1,3659,1,0,19566 +9-Nov-19,0,129,29,15583,0,3449,3,0,19193 +2-Nov-19,0,124,32,15773,0,3488,3,0,19420 +26-Oct-19,0,138,29,15742,0,3345,0,0,19254 +19-Oct-19,0,129,27,15927,1,3347,0,0,19431 +12-Oct-19,0,127,31,15761,0,3414,1,0,19334 +5-Oct-19,0,143,38,15753,0,3405,1,0,19340 +28-Sep-19,0,139,30,15852,1,3428,1,0,19451 +21-Sep-19,0,129,29,15753,0,3194,0,0,19105 +14-Sep-19,0,131,30,15994,0,3107,0,0,19262 +7-Sep-19,0,134,30,15560,0,3222,0,0,18946 +31-Aug-19,0,133,35,15642,1,3279,0,0,19090 +24-Aug-19,0,131,33,15477,0,3163,0,0,18804 +17-Aug-19,0,145,34,15513,0,3095,0,0,18787 +10-Aug-19,0,144,29,15331,1,2854,0,0,18359 +3-Aug-19,0,150,30,15495,0,2924,0,0,18599 +27-Jul-19,0,135,32,15766,0,2942,0,0,18875 +20-Jul-19,0,145,35,15715,1,2864,0,0,18760 +13-Jul-19,0,141,29,15700,0,2975,0,0,18845 +6-Jul-19,0,140,32,15572,0,3223,0,0,18967 +29-Jun-19,0,153,32,15719,1,3087,0,0,18992 +22-Jun-19,0,151,33,15604,1,3045,0,0,18834 +15-Jun-19,0,147,35,15576,0,3226,0,0,18984 +8-Jun-19,0,150,29,15515,0,2942,0,0,18636 +1-Jun-19,0,140,37,15912,0,2724,0,0,18813 +25-May-19,0,145,32,15847,1,2803,0,0,18828 +18-May-19,0,142,31,15985,0,3094,0,0,19252 +11-May-19,0,142,31,16276,2,3083,0,0,19534 +4-May-19,0,131,30,16204,8,3001,0,0,19374 +27-Apr-19,0,126,35,15858,7,2788,0,0,18814 +20-Apr-19,0,134,30,15809,4,2635,0,0,18612 +13-Apr-19,0,136,32,15934,8,2545,0,0,18655 +6-Apr-19,0,136,36,15967,7,2652,0,0,18798 +30-Mar-19,0,135,37,16346,6,2608,0,0,19132 +23-Mar-19,0,135,32,16429,6,2537,0,0,19139 +16-Mar-19,0,131,32,16638,4,2370,0,0,19175 +9-Mar-19,0,138,32,16351,5,2300,0,0,18826 +2-Mar-19,0,141,34,16383,6,2436,0,0,19000 +23-Feb-19,0,145,31,16483,7,2458,0,0,19124 +16-Feb-19,0,143,32,16565,6,2689,0,0,19435 +9-Feb-19,0,143,37,16169,5,2551,0,0,18905 +2-Feb-19,0,142,32,16180,4,2437,0,0,18795 +26-Jan-19,0,136,34,16314,5,2474,0,0,18963 +19-Jan-19,0,138,30,16504,6,2276,0,0,18954 +12-Jan-19,0,140,28,16585,7,2294,0,0,19054 +5-Jan-19,0,140,28,16175,7,2122,0,0,18472 +29-Dec-18,0,145,31,15571,7,1974,0,0,17728 +22-Dec-18,0,144,30,15031,4,2009,0,0,17218 +15-Dec-18,0,145,30,15884,5,2077,0,0,18141 +8-Dec-18,0,140,35,16095,5,2193,0,0,18468 +1-Dec-18,0,148,31,16110,3,2268,0,0,18560 +24-Nov-18,0,145,27,15817,3,2096,0,0,18088 +17-Nov-18,0,153,25,15964,6,2104,0,0,18252 +10-Nov-18,0,153,33,15995,7,2074,0,0,18262 +3-Nov-18,0,158,34,15944,5,2319,0,0,18460 +27-Oct-18,0,153,34,15920,4,2400,0,0,18511 +20-Oct-18,0,150,31,16208,5,2237,0,0,18631 +13-Oct-18,0,153,30,16470,4,2200,0,0,18857 +6-Oct-18,0,153,32,15848,4,1926,0,0,17963 +29-Sep-18,0,155,36,15769,5,1929,0,0,17894 +22-Sep-18,0,157,29,15572,4,1852,0,0,17614 +15-Sep-18,0,158,29,15334,5,1998,0,0,17524 +8-Sep-18,0,161,28,15815,4,1974,0,0,17982 +1-Sep-18,0,162,30,15825,4,2001,0,0,18022 +25-Aug-18,0,156,29,15769,4,1929,0,0,17887 +18-Aug-18,0,152,29,15565,4,1775,0,0,17525 +11-Aug-18,0,150,30,15460,4,1860,0,0,17504 +4-Aug-18,0,157,36,15504,5,1979,0,0,17681 +28-Jul-18,0,157,32,15243,2,1898,0,0,17332 +21-Jul-18,0,161,31,15322,0,1850,0,0,17364 +14-Jul-18,0,138,34,14741,0,1725,0,0,16638 +7-Jul-18,0,158,35,15621,0,1700,0,0,17514 +30-Jun-18,0,145,31,13791,0,1442,0,0,15409 +23-Jun-18,0,165,36,15441,0,1594,0,0,17236 +16-Jun-18,0,162,35,15550,0,1633,0,0,17380 +9-Jun-18,0,157,39,15502,0,1650,0,0,17348 +2-Jun-18,0,167,34,15757,3,1795,0,0,17756 +26-May-18,0,176,33,15551,0,1487,0,0,17247 +19-May-18,0,173,37,15841,0,1554,0,0,17605 +12-May-18,0,168,30,16140,0,1400,0,0,17738 +5-May-18,0,174,34,16049,0,1460,0,0,17717 +28-Apr-18,0,216,35,16600,0,1485,0,0,18336 +21-Apr-18,0,181,33,18067,0,1517,0,0,19798 +14-Apr-18,0,181,32,16873,2,1712,0,0,18800 +7-Apr-18,0,177,38,16800,4,1558,0,0,18577 +31-Mar-18,0,184,32,16736,2,1457,0,0,18411 +24-Mar-18,0,195,31,17965,3,1458,0,0,19652 +17-Mar-18,0,184,35,16418,2,1380,0,0,18019 +10-Mar-18,0,184,32,16795,2,1419,0,0,18432 +3-Mar-18,0,193,35,16780,2,1520,0,0,18530 +24-Feb-18,0,190,31,16427,2,1338,0,0,17988 +17-Feb-18,0,204,34,16367,2,1274,0,0,17881 +10-Feb-18,0,196,36,16275,1,1292,0,0,17800 +3-Feb-18,0,206,34,16345,3,1527,0,0,18115 +27-Jan-18,0,196,33,16268,5,1385,0,0,17887 +20-Jan-18,0,202,36,15935,2,1450,0,0,17625 +13-Jan-18,0,200,33,15856,7,1320,0,0,17416 +6-Jan-18,0,209,38,16034,3,1285,0,0,17569 +30-Dec-17,0,204,39,15544,2,1056,0,0,16845 +23-Dec-17,0,206,35,15002,2,1039,0,0,16284 +16-Dec-17,0,203,35,15809,3,1123,0,0,17173 +9-Dec-17,0,206,32,15921,2,1477,0,0,17638 +2-Dec-17,0,204,38,16419,2,1339,0,0,18002 +25-Nov-17,0,210,33,16087,2,1193,0,0,17525 +18-Nov-17,0,205,39,16106,3,1096,0,0,17449 +11-Nov-17,0,210,39,16304,2,1081,0,0,17636 +4-Nov-17,0,227,40,16241,3,1163,0,0,17674 +28-Oct-17,0,217,39,15860,4,909,0,0,17029 +21-Oct-17,0,226,39,15948,4,863,0,0,17080 +14-Oct-17,0,227,43,16086,3,922,0,0,17281 +7-Oct-17,0,235,45,16215,2,932,0,0,17429 +30-Sep-17,0,237,38,16037,2,800,0,0,17114 +23-Sep-17,0,243,42,15838,4,715,0,0,16842 +16-Sep-17,0,239,39,15716,4,962,0,0,16960 +9-Sep-17,0,242,44,15784,2,659,0,0,16731 +2-Sep-17,0,254,46,15812,2,760,0,0,16874 +26-Aug-17,0,254,43,15805,2,681,0,0,16785 +19-Aug-17,0,265,45,15748,3,533,0,0,16594 +12-Aug-17,0,242,38,14836,2,474,0,0,15592 +5-Aug-17,0,264,39,15242,3,572,0,0,16120 +29-Jul-17,0,263,41,15266,1,288,0,0,15859 +22-Jul-17,0,259,44,15357,2,134,0,0,15796 +15-Jul-17,0,269,38,15512,2,132,0,0,15953 +8-Jul-17,0,261,47,15717,3,124,0,0,16152 +1-Jul-17,0,262,43,15617,3,114,0,0,16039 +24-Jun-17,0,261,44,15799,3,113,0,0,16220 +17-Jun-17,0,270,42,15745,3,107,0,0,16167 +10-Jun-17,0,279,42,15587,4,112,0,0,16024 +3-Jun-17,0,278,49,15721,4,105,0,0,16157 +27-May-17,0,289,50,15553,4,100,0,0,15996 +20-May-17,0,286,45,15504,2,92,0,0,15929 +13-May-17,0,290,46,16796,2,93,0,0,17227 +6-May-17,0,286,45,17445,4,74,0,0,17854 +29-Apr-17,0,297,46,17294,3,62,0,0,17702 +22-Apr-17,0,296,46,17500,3,63,0,0,17908 +15-Apr-17,0,290,48,17485,3,47,0,0,17873 +8-Apr-17,0,293,46,17367,3,36,0,0,17745 +1-Apr-17,0,298,43,17508,7,31,0,0,17887 +25-Mar-17,0,300,47,17271,10,27,0,0,17655 +18-Mar-17,0,304,47,17267,14,10,0,0,17642 +11-Mar-17,0,296,43,16712,19,0,0,0,17070 +4-Mar-17,0,303,46,16999,3,0,0,0,17351 +25-Feb-17,0,307,48,16959,0,0,0,0,17314 +18-Feb-17,0,320,45,16806,0,0,0,0,17171 +11-Feb-17,0,305,40,16866,0,0,0,0,17211 +4-Feb-17,0,294,42,16988,0,0,0,0,17324 +28-Jan-17,0,312,43,16604,0,0,0,0,16959 +21-Jan-17,0,291,48,16706,0,0,0,0,17045 +14-Jan-17,0,310,46,16459,0,0,0,0,16815 +7-Jan-17,0,318,51,16878,0,0,0,0,17247 +31-Dec-16,0,318,45,15947,0,0,0,0,16310 +24-Dec-16,0,311,45,15031,0,0,0,0,15387 +17-Dec-16,0,319,46,16005,0,0,0,0,16370 +10-Dec-16,0,318,42,16210,0,0,0,0,16570 +3-Dec-16,0,332,52,16549,0,0,0,0,16933 +26-Nov-16,0,334,46,16549,0,0,0,0,16929 +19-Nov-16,0,332,51,15957,0,0,0,0,16340 +12-Nov-16,0,342,49,16191,0,0,0,0,16582 +5-Nov-16,0,345,56,15339,0,0,0,0,15740 +29-Oct-16,0,349,49,14911,0,0,0,0,15309 +22-Oct-16,0,350,53,15040,0,0,0,0,15443 +15-Oct-16,0,348,53,15238,0,0,0,0,15639 +8-Oct-16,0,350,52,14567,0,0,0,0,14969 +1-Oct-16,0,352,63,15041,0,0,0,0,15456 +24-Sep-16,0,351,60,14551,0,0,0,0,14962 +17-Sep-16,0,347,58,14655,0,0,0,0,15060 +10-Sep-16,0,338,62,14584,0,0,0,0,14984 +3-Sep-16,0,355,62,14582,0,0,0,0,14999 +27-Aug-16,0,352,67,14673,0,0,0,0,15092 +20-Aug-16,0,359,63,14400,0,0,0,0,14822 +13-Aug-16,0,392,61,14415,0,0,0,0,14868 +6-Aug-16,0,373,62,14653,0,0,0,0,15088 +30-Jul-16,0,367,60,14607,0,0,0,0,15034 +23-Jul-16,0,374,58,14741,0,0,0,0,15173 +16-Jul-16,0,415,57,14959,0,0,0,0,15431 +9-Jul-16,0,439,58,13846,0,0,0,0,14343 +2-Jul-16,0,378,59,14610,0,0,0,0,15047 +25-Jun-16,0,392,62,14607,0,0,0,0,15061 +18-Jun-16,0,380,65,14953,0,0,0,0,15398 +11-Jun-16,0,398,58,14869,0,0,0,0,15325 +4-Jun-16,0,379,63,14444,0,0,0,0,14886 +28-May-16,0,406,68,14443,0,0,0,0,14917 +21-May-16,0,396,68,14739,0,0,0,0,15203 +14-May-16,0,391,58,14553,0,0,0,0,15002 +7-May-16,0,393,72,14577,0,0,0,0,15042 +30-Apr-16,0,391,67,14347,0,0,0,0,14805 +23-Apr-16,0,412,65,14447,0,0,0,0,14924 +16-Apr-16,0,392,65,14611,0,0,0,0,15068 +9-Apr-16,0,414,68,14426,0,0,0,0,14908 +2-Apr-16,0,410,67,14537,0,0,0,0,15014 +26-Mar-16,0,407,74,14040,0,0,0,0,14521 +19-Mar-16,0,418,73,13893,0,0,0,0,14384 +12-Mar-16,0,419,76,14256,0,0,0,0,14751 +5-Mar-16,0,440,73,14272,0,0,0,0,14785 +27-Feb-16,0,437,78,14293,0,0,0,0,14808 +20-Feb-16,0,448,78,14500,0,0,0,0,15026 +13-Feb-16,0,458,74,13949,0,0,0,0,14481 +6-Feb-16,0,437,76,13681,0,0,0,0,14194 +30-Jan-16,0,428,76,13729,0,0,0,0,14233 +23-Jan-16,0,435,80,13393,0,0,0,0,13908 +16-Jan-16,0,441,79,13369,0,0,0,0,13889 +9-Jan-16,0,443,77,13298,0,0,0,0,13818 +2-Jan-16,0,450,77,12385,0,0,0,0,12912 +26-Dec-15,0,446,84,11190,0,0,0,0,11720 +19-Dec-15,0,452,92,11888,0,0,0,0,12432 +12-Dec-15,0,468,84,12623,0,0,0,0,13175 +5-Dec-15,0,465,87,12736,0,0,0,0,13288 +28-Nov-15,0,460,86,12721,0,0,0,0,13267 +21-Nov-15,0,465,88,12423,0,0,0,0,12976 +14-Nov-15,0,494,90,12483,0,0,0,0,13067 +7-Nov-15,0,489,89,12383,0,0,0,0,12961 +31-Oct-15,0,509,87,12512,0,0,0,0,13108 +24-Oct-15,0,495,88,12346,0,0,0,0,12929 +17-Oct-15,0,505,90,12338,0,0,0,0,12933 +10-Oct-15,0,487,96,11995,0,0,0,0,12578 +3-Oct-15,0,489,87,11676,0,0,0,0,12252 +26-Sep-15,0,480,93,11015,0,0,0,0,11588 +19-Sep-15,0,474,85,10385,0,0,0,0,10944 +12-Sep-15,0,506,94,11041,0,0,0,0,11641 +5-Sep-15,0,519,101,11402,0,0,0,0,12022 +29-Aug-15,0,528,106,11556,0,0,0,0,12190 +22-Aug-15,0,596,106,11672,0,0,0,0,12374 +15-Aug-15,0,530,105,11574,0,0,0,0,12209 +8-Aug-15,1,520,103,11089,0,0,0,0,11713 +1-Aug-15,1,507,106,10947,0,0,0,0,11561 +25-Jul-15,1,593,107,10712,0,0,0,0,11413 +18-Jul-15,1,520,104,10533,0,0,0,0,11158 +11-Jul-15,1,538,110,10764,0,0,0,0,11413 +4-Jul-15,1,550,108,10918,0,0,0,0,11577 +27-Jun-15,1,537,114,10783,0,0,0,0,11435 +20-Jun-15,1,564,109,10961,0,0,0,0,11635 +13-Jun-15,1,550,114,10845,0,0,0,0,11510 +6-Jun-15,1,540,112,10369,0,0,0,0,11022 +30-May-15,1,537,115,10137,0,0,0,0,10790 +23-May-15,1,532,116,10024,0,0,0,0,10673 +16-May-15,1,556,115,10051,0,0,0,0,10723 +9-May-15,1,549,112,9923,0,0,0,0,10585 +2-May-15,1,549,115,9700,0,0,0,0,10365 +25-Apr-15,1,555,118,9462,0,0,0,0,10136 +18-Apr-15,1,552,121,9363,0,0,0,0,10037 +11-Apr-15,1,549,122,9046,0,0,0,0,9718 +4-Apr-15,1,561,130,8955,0,0,0,0,9647 +28-Mar-15,1,570,125,9064,0,0,0,0,9760 +21-Mar-15,1,568,134,9194,0,0,0,0,9897 +14-Mar-15,1,561,137,9163,0,0,0,0,9862 +7-Mar-15,1,553,127,8826,0,0,0,0,9507 +28-Feb-15,1,587,130,8638,0,0,0,0,9356 +21-Feb-15,1,584,124,8459,0,0,0,0,9168 +14-Feb-15,1,588,136,8314,0,0,0,0,9039 +7-Feb-15,1,577,142,8240,0,0,0,0,8960 +31-Jan-15,1,583,133,8003,0,0,0,0,8720 +24-Jan-15,1,598,130,7738,0,0,0,0,8467 +17-Jan-15,1,593,138,7779,0,0,0,0,8511 +10-Jan-15,1,579,137,7654,0,0,0,0,8371 +3-Jan-15,1,581,127,7500,0,0,0,0,8209 +27-Dec-14,1,562,131,6455,0,0,0,0,7149 +20-Dec-14,1,582,129,6575,0,0,0,0,7287 +13-Dec-14,1,602,134,7342,0,0,0,0,8079 +6-Dec-14,1,602,139,7319,0,0,0,0,8061 +29-Nov-14,1,609,134,7171,0,0,0,0,7915 +22-Nov-14,1,630,144,7088,0,0,0,0,7863 +15-Nov-14,1,625,160,7301,0,0,0,0,8087 +8-Nov-14,1,617,151,7029,0,0,0,0,7798 +1-Nov-14,2,635,166,6769,0,0,0,0,7572 +25-Oct-14,1,614,160,6508,0,0,0,0,7283 +18-Oct-14,1,609,159,6348,0,0,0,0,7117 +11-Oct-14,1,627,155,6349,0,0,0,0,7132 +4-Oct-14,1,621,165,5897,0,0,0,0,6684 +27-Sep-14,1,622,158,5854,0,0,0,0,6635 +20-Sep-14,1,633,164,5881,0,0,0,0,6679 +13-Sep-14,1,654,161,5789,0,0,0,0,6605 +6-Sep-14,1,652,166,5700,0,0,0,0,6519 +30-Aug-14,2,643,171,5580,0,0,0,0,6396 +23-Aug-14,2,643,171,5466,0,0,0,0,6282 +16-Aug-14,2,633,184,5304,0,0,0,0,6123 +9-Aug-14,2,648,185,5183,0,0,0,0,6018 +2-Aug-14,2,641,183,5247,0,0,0,0,6073 +26-Jul-14,2,643,185,4938,0,0,0,0,5768 +19-Jul-14,2,640,183,4814,0,0,0,0,5639 +12-Jul-14,2,719,175,4713,0,0,0,0,5609 +5-Jul-14,2,652,177,4543,0,0,0,0,5374 +28-Jun-14,2,653,182,4526,0,0,0,0,5363 +21-Jun-14,2,655,181,4490,0,0,0,0,5328 +14-Jun-14,2,651,166,3895,0,0,0,0,4714 +7-Jun-14,2,674,187,3866,0,0,0,0,4729 +31-May-14,2,665,183,3623,0,0,0,0,4473 +24-May-14,2,669,194,3581,0,0,0,0,4446 +17-May-14,2,669,191,3346,0,0,0,0,4208 +10-May-14,2,671,196,3221,0,0,0,0,4090 +3-May-14,2,661,192,3223,0,0,0,0,4078 +26-Apr-14,2,681,194,3095,0,0,0,0,3972 +19-Apr-14,2,687,201,2981,0,0,0,0,3871 +12-Apr-14,2,695,203,2910,0,0,0,0,3810 +5-Apr-14,2,694,218,2807,0,0,0,0,3721 +29-Mar-14,2,725,222,2807,0,0,0,0,3756 +22-Mar-14,2,709,226,2661,0,0,0,0,3598 +15-Mar-14,2,712,235,2544,0,0,0,0,3493 +8-Mar-14,2,747,241,2405,0,0,0,0,3395 +1-Mar-14,2,715,255,2368,0,0,0,0,3340 +22-Feb-14,2,748,244,2245,0,0,0,0,3239 +15-Feb-14,2,707,248,2107,0,0,0,0,3064 +8-Feb-14,2,759,256,2044,0,0,0,0,3061 +1-Feb-14,2,734,258,1928,0,0,0,0,2922 +25-Jan-14,2,753,263,1781,0,0,0,0,2799 +18-Jan-14,2,749,260,1722,0,0,0,0,2733 +11-Jan-14,2,728,288,1612,0,0,0,0,2630 +4-Jan-14,2,755,276,1343,0,0,0,0,2376 +28-Dec-13,2,736,285,1082,0,0,0,0,2105 +21-Dec-13,2,711,276,1005,0,0,0,0,1994 +14-Dec-13,2,769,285,1118,0,0,0,0,2174 +7-Dec-13,2,756,281,1008,0,0,0,0,2047 +30-Nov-13,2,799,286,920,0,0,0,0,2007 +23-Nov-13,2,898,290,849,0,0,0,0,2039 +16-Nov-13,2,956,313,790,0,0,0,0,2061 +9-Nov-13,2,939,313,746,0,0,0,0,2000 +2-Nov-13,2,963,327,662,0,0,0,0,1954 +26-Oct-13,2,992,322,636,0,0,0,0,1952 +19-Oct-13,2,977,309,651,0,0,0,0,1939 +12-Oct-13,2,990,328,576,0,0,0,0,1896 +5-Oct-13,2,887,328,560,0,0,0,0,1777 +28-Sep-13,2,1450,331,516,0,0,0,0,2299 +21-Sep-13,2,1510,336,449,0,0,0,0,2297 +14-Sep-13,2,1530,335,423,0,0,0,0,2290 +7-Sep-13,2,874,340,363,0,0,0,0,1579 +31-Aug-13,2,887,353,317,0,0,0,0,1559 +24-Aug-13,2,870,349,269,0,0,0,0,1490 +17-Aug-13,2,877,352,235,0,0,0,0,1466 +10-Aug-13,2,862,367,190,0,0,0,0,1421 +3-Aug-13,2,841,399,149,0,0,0,0,1391 +27-Jul-13,2,876,416,104,0,0,0,0,1398 +20-Jul-13,2,887,457,15,0,0,0,0,1361 +13-Jul-13,2,879,445,0,0,0,0,0,1326 +6-Jul-13,2,876,462,0,0,0,0,0,1340 +29-Jun-13,2,884,464,0,0,0,0,0,1350 +22-Jun-13,2,901,452,0,0,0,0,0,1355 +15-Jun-13,2,893,460,0,0,0,0,0,1355 +8-Jun-13,2,956,474,0,0,0,0,0,1432 +1-Jun-13,2,1117,473,0,0,0,0,0,1592 +25-May-13,2,1063,441,0,0,0,0,0,1506 +18-May-13,2,944,383,0,0,0,0,0,1329 +11-May-13,2,1109,450,0,0,0,0,0,1561 +4-May-13,2,1020,451,0,0,0,0,0,1473 +27-Apr-13,2,1003,450,0,0,0,0,0,1455 +20-Apr-13,2,967,448,0,0,0,0,0,1417 +13-Apr-13,2,1061,469,0,0,0,0,0,1532 +6-Apr-13,2,1022,478,0,0,0,0,0,1502 +30-Mar-13,2,1030,440,0,0,0,0,0,1472 +23-Mar-13,2,1101,464,0,0,0,0,0,1567 +16-Mar-13,2,1047,451,0,0,0,0,0,1500 +9-Mar-13,2,1046,476,0,0,0,0,0,1524 +2-Mar-13,3,1044,469,0,0,0,0,0,1516 +23-Feb-13,3,1077,479,0,0,0,0,0,1559 +16-Feb-13,3,1094,458,0,0,0,0,0,1555 +9-Feb-13,3,1091,468,0,0,0,0,0,1562 +2-Feb-13,3,1099,447,0,0,0,0,0,1549 +26-Jan-13,3,1132,452,0,0,0,0,0,1587 +19-Jan-13,3,1124,455,0,0,0,0,0,1582 +12-Jan-13,4,1164,446,0,0,0,0,0,1614 +5-Jan-13,3,1167,443,0,0,0,0,0,1613 +29-Dec-12,3,1085,411,0,0,0,0,0,1499 +22-Dec-12,3,1076,407,0,0,0,0,0,1486 +15-Dec-12,4,1173,437,0,0,0,0,0,1614 +8-Dec-12,4,1208,441,0,0,0,0,0,1653 +1-Dec-12,3,1171,476,0,0,0,0,0,1650 +24-Nov-12,3,1185,474,0,0,0,0,0,1662 +17-Nov-12,3,1163,460,0,0,0,0,0,1626 +10-Nov-12,3,1200,451,0,0,0,0,0,1654 +3-Nov-12,3,1247,476,0,0,0,0,0,1726 +27-Oct-12,3,1219,456,0,0,0,0,0,1678 +20-Oct-12,3,1276,443,0,0,0,0,0,1722 diff --git a/web/modules/charts/modules/charts_api_example/js/charts_api_example_js_override.js b/web/modules/charts/modules/charts_api_example/js/charts_api_example_js_override.js new file mode 100644 index 00000000..93dcfa62 --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/js/charts_api_example_js_override.js @@ -0,0 +1,24 @@ +(function () { + 'use strict'; + Drupal.charts_api_example = Drupal.charts_api_example || {}; + // PLEASE NOTE: this example is structured to work for Highcharts, but you + // do the same thing for any of the charting libraries. + Drupal.charts_api_example.highchartsTooltipFormatter = function () { + return 'The value for ' + this.x + + ' is ' + this.y + ''; + }; + Drupal.behaviors.chartsApiExampleCharts = { + attach: function (context, settings) { + let chartcontainer = document.getElementById('exampleidjs'); + chartcontainer.addEventListener('drupalChartsConfigsInitialization', function (e) { + let data = e.detail; + const id = data.drupalChartDivId; + // Change the background of the chart to green. + data.chart.backgroundColor = 'green'; + // Replace the tooltip formatter. + data.tooltip.formatter = Drupal.charts_api_example.highchartsTooltipFormatter; + Drupal.Charts.Contents.update(id, data); + }); + } + }; +}()); diff --git a/web/modules/charts/modules/charts_api_example/src/Controller/ChartsApiExample.php b/web/modules/charts/modules/charts_api_example/src/Controller/ChartsApiExample.php new file mode 100755 index 00000000..15085449 --- /dev/null +++ b/web/modules/charts/modules/charts_api_example/src/Controller/ChartsApiExample.php @@ -0,0 +1,400 @@ +messenger = $messenger; + $this->uuidService = $uuidService; + $this->moduleList = $module_list; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('messenger'), + $container->get('uuid'), + $container->get('extension.list.module') + ); + } + + /** + * Display the dashboard of charts. + * + * @return array + * Array to render. + */ + public function display() { + + /* + * If you do not set a library specifically in your render array, Charts + * will use your default. But you do need to set your default library. + */ + $charts_settings = $this->config('charts.settings'); + $library = $charts_settings->get('charts_default_settings.library'); + if (empty($library)) { + $this->messenger->addError($this->t('You need to first configure Charts default settings')); + return []; + } + + // This is a container for all the charts below. + $charts_container = [ + '#type' => 'container', + 'content' => [], + ]; + + /* Listing all the default chart types except 'gauge' and 'scatter' + * (they come later). + */ + $chart_types = ['area', 'bar', 'column', 'line', 'spline', 'pie', 'donut']; + + // Define a series to be used in multiple examples. + $series = [ + '#type' => 'chart_data', + '#title' => $this->t('5.0.x'), + '#data' => [257, 235, 325, 340], + '#color' => '#1d84c3', + ]; + + // Define an x-axis to be used in multiple examples. + $xaxis = [ + '#type' => 'chart_xaxis', + '#title' => $this->t('Months'), + '#labels' => [ + $this->t('January 2021'), + $this->t('February 2021'), + $this->t('March 2021'), + $this->t('April 2021'), + ], + ]; + + // Define a y-axis to be used in multiple examples. + $yaxis = [ + '#type' => 'chart_yaxis', + '#title' => $this->t('Number of Installs'), + ]; + + // Iterate through the chart types and build a chart for each. + foreach ($chart_types as $type) { + $charts_container['content'][$type] = [ + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library @type Chart', [ + '@library' => ucfirst($library), + '@type' => ucfirst($type), + ]), + '#chart_type' => $type, + 'series' => $series, + 'x_axis' => $xaxis, + 'y_axis' => $yaxis, + '#raw_options' => [], + // e.g. ['chart' => ['backgroundColor' => '#000000']]. + ]; + } + + // Column chart with two series. + $charts_container['content']['two_series_column'] = [ + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Column Chart (Two Series)', ['@library' => ucfirst($library)]), + '#chart_type' => 'column', + 'series_one' => $series, + 'series_two' => [ + '#type' => 'chart_data', + '#title' => $this->t('8.x-3.x'), + '#data' => [4330, 4413, 4212, 4431], + '#color' => '#77b259', + ], + 'x_axis' => $xaxis, + 'y_axis' => $yaxis, + '#raw_options' => [], + ]; + + // Stacked Area Chart from a local CSV file. + $file_contents = $this->getCsvContents(); + $charts_container['content']['from_csv_file'] = [ + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Stacked Area Chart from CSV File', ['@library' => ucfirst($library)]), + '#chart_type' => 'area', + 'series_seven_2' => [ + '#type' => 'chart_data', + '#title' => $this->t('7.x-2.x'), + // Using array_reverse because my file is desc rather than asc. + '#data' => array_reverse($file_contents['7.x-2.x']), + '#color' => '#76b7b2', + ], + 'series_eight_three' => [ + '#type' => 'chart_data', + '#title' => $this->t('8.x-3.x'), + '#data' => array_reverse($file_contents['8.x-3.x']), + '#color' => '#edc949', + ], + 'series_five_zero' => [ + '#type' => 'chart_data', + '#title' => $this->t('5.0.x'), + '#data' => array_reverse($file_contents['5.0.x']), + '#color' => '#ff9da7', + ], + 'x_axis' => [ + '#type' => 'chart_xaxis', + '#title' => $this->t('Week'), + '#labels' => array_reverse($file_contents['Week']), + ], + 'y_axis' => [ + '#type' => 'chart_yaxis', + '#title' => $this->t('Number of Installs'), + ], + '#stacking' => TRUE, + ]; + if ($library === 'chartjs') { + // A real-life #raw_options use-case. + $charts_container['content']['from_csv_file']['#raw_options'] = [ + 'options' => [ + 'scales' => [ + 'x' => [ + 'ticks' => [ + 'autoSkip' => TRUE, + ], + ], + ], + ], + ]; + } + elseif ($library === 'billboard' || $library === 'c3') { + $charts_container['content']['from_csv_file']['#raw_options'] = [ + 'axis' => [ + 'x' => [ + 'tick' => [ + 'culling' => TRUE, + ], + ], + ], + ]; + } + + // Stacked column chart with two series. + $charts_container['content']['stacked_two_series_column'] = [ + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Stacked Column Chart (Two Series)', ['@library' => ucfirst($library)]), + '#chart_type' => 'column', + 'series_one' => $series, + 'series_two' => [ + '#type' => 'chart_data', + '#title' => $this->t('8.x-3.x'), + '#data' => [4330, 4413, 4212, 4431], + '#color' => '#77b259', + ], + 'x_axis' => $xaxis, + 'y_axis' => $yaxis, + '#stacking' => TRUE, + '#raw_options' => [], + ]; + + // Combination chart (column and line). + $charts_container['content']['combo'] = [ + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Combination Chart', ['@library' => ucfirst($library)]), + '#chart_type' => 'column', + 'series_one' => $series, + 'series_two' => [ + '#type' => 'chart_data', + '#chart_type' => 'line', + '#title' => $this->t('8.x-3.x'), + '#data' => [4330, 4413, 4212, 4431], + '#color' => '#77b259', + ], + 'x_axis' => $xaxis, + 'y_axis' => $yaxis, + '#raw_options' => [], + ]; + + // Combination chart (column and line) with secondary Y-Axis. + $charts_container['content']['combo_dual_yaxes'] = [ + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Combination Chart with Secondary Y-Axis', ['@library' => ucfirst($library)]), + '#chart_type' => 'column', + 'series_one' => $series, + 'series_two' => [ + '#type' => 'chart_data', + '#chart_type' => 'line', + '#title' => $this->t('8.x-3.x'), + '#data' => [4330, 4413, 4212, 4431], + '#color' => '#77b259', + '#target_axis' => 'y_axis_secondary', + ], + 'x_axis' => $xaxis, + 'y_axis' => $yaxis, + 'y_axis_secondary' => [ + '#type' => 'chart_yaxis', + '#title' => $this->t('Secondary Y-Axis'), + '#opposite' => TRUE, + ], + '#raw_options' => [], + ]; + + // Radar chart. Not supported by C3.js or Google Charts (natively). + if (!in_array($library, ['c3', 'google'])) { + $charts_container['content']['radar'] = [ + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Radar Chart', ['@library' => ucfirst($library)]), + '#chart_type' => 'line', + 'series' => $series, + 'x_axis' => $xaxis, + 'y_axis' => $yaxis, + '#polar' => TRUE, + '#raw_options' => [], + ]; + } + + // Gauge chart. Not yet supported by Chart.js (as of 3.5.1). + if ($library !== 'chartjs') { + $charts_container['content']['gauge'] = [ + '#type' => 'chart', + '#title' => $this->t('@library Gauge Chart', ['@library' => ucfirst($library)]), + '#chart_type' => 'gauge', + '#gauge' => [ + 'green_to' => 100, + 'green_from' => 75, + 'yellow_to' => 74, + 'yellow_from' => 50, + 'red_to' => 49, + 'red_from' => 0, + 'max' => 100, + 'min' => 0, + ], + 'series' => [ + '#type' => 'chart_data', + '#title' => $this->t('Speed'), + '#data' => [65], + ], + '#raw_options' => [], + ]; + } + + // Scatter chart. + $charts_container['content']['scatter'] = [ + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Scatter Chart', ['@library' => ucfirst($library)]), + '#data_markers' => TRUE, + '#chart_type' => 'scatter', + 'series' => [ + '#type' => 'chart_data', + '#title' => $this->t('Group 1'), + '#data' => [[162.2, 51.8], [164.5, 58.0], [160.5, 49.6], [154.0, 65.0]], + ], + 'x_axis' => [ + '#type' => 'chart_xaxis', + '#title' => $this->t('Height'), + '#labels' => [], + ], + 'y_axis' => [ + '#type' => 'chart_yaxis', + '#title' => $this->t('Weight'), + ], + '#stacking' => TRUE, + '#raw_options' => [], + ]; + + if ($library === 'highcharts') { + $charts_container['content']['php_override'] = [ + '#chart_id' => 'exampleidphp', + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Chart, Overridden By PHP Hook', ['@library' => ucfirst($library)]), + '#chart_type' => 'column', + 'series' => $series, + 'x_axis' => $xaxis, + 'y_axis' => $yaxis, + '#color_changer' => TRUE, + '#raw_options' => [], + ]; + + $charts_container['content']['js_override'] = [ + '#id' => 'exampleidjs', + '#chart_id' => 'exampleidjschart', + '#type' => 'chart', + '#tooltips' => $charts_settings->get('charts_default_settings.display.tooltips'), + '#title' => $this->t('@library Chart, Overridden By JS Function', ['@library' => ucfirst($library)]), + '#chart_type' => 'column', + 'series' => $series, + 'x_axis' => $xaxis, + 'y_axis' => $yaxis, + '#raw_options' => [], + ]; + } + + return $charts_container; + } + + /** + * Returns the CSV contents in an array with data organized by column. + * + * @return array + * The array of rows. + */ + private function getCsvContents() { + $file_path = $this->moduleList->getPath('charts_api_example'); + $file_name = $file_path . '/fixtures/charts_api_example_file.csv'; + $handle = fopen($file_name, 'r'); + $all_rows = []; + while ($row = fgetcsv($handle)) { + $all_rows['Week'][] = $row[0]; + $all_rows['7.x-2.x'][] = (int) $row[4]; + $all_rows['8.x-3.x'][] = (int) $row[6]; + $all_rows['5.0.x'][] = (int) $row[8]; + } + fclose($handle); + + return $all_rows; + } + +} diff --git a/web/modules/charts/modules/charts_billboard/README.md b/web/modules/charts/modules/charts_billboard/README.md new file mode 100755 index 00000000..5f3d07e9 --- /dev/null +++ b/web/modules/charts/modules/charts_billboard/README.md @@ -0,0 +1,117 @@ +#Installation Using Composer (recommended) + +If you use Composer to manage dependencies, edit your site's "composer.json" +file as follows. + +1. Run the following command to ensure that you have the "composer/installers" +package installed. This package facilitates the installation of packages into +directories other than "/vendor" (e.g. "/libraries") using Composer. + + composer require --prefer-dist composer/installers + +2. Add the following to the "installer-paths" section of "composer.json": + + "libraries/{$name}": ["type:drupal-library"], + +3. Add the following to the "repositories" section of "composer.json": + + { + "type": "package", + "package": { + "name": "billboardjs/billboard", + "version": "3.10.3", + "type": "drupal-library", + "extra": { + "installer-name": "billboard" + }, + "dist": { + "url": "https://registry.npmjs.org/billboard.js/-/billboard.js-3.10.3.tgz", + "type": "tar" + } + } + }, + { + "type": "package", + "package": { + "name": "d3/d3", + "version": "7.8.5", + "type": "drupal-library", + "extra": { + "installer-name": "d3" + }, + "dist": { + "url": "https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + } + +4. This and the following step is optional but recommended. The reason for +them is that when installing the Billboard.js package with Composer, +additional files are added into the library directory. These files are not +necessary and can be potentially harmful to your site, so it's best to remove +them. So: create a new directory in your project root called "scripts". +5. Inside that directory, create a new file called "clean-billboardjs.sh" and +paste the following into it: + + #!/usr/bin/env bash + set -eu + declare -a directories=( + "web/libraries/billboard/dist-esm" + "web/libraries/billboard/src" + "web/libraries/billboard/types" + "web/libraries/billboard/dist/plugin" + "web/libraries/billboard/dist/theme" + ) + counter=0 + echo "Deleting unneeded directories inside web/libraries/billboard" + for directory in "${directories[@]}" + do + if [ -d $directory ]; then + echo "Deleting $directory" + rm -rf $directory + counter=$((counter+1)) + fi + done + echo "$counter folders were deleted" + declare -a files=( + "web/libraries/billboard/CONTRIBUTING.md" + "web/libraries/billboard/README.md" + "web/libraries/billboard/LICENSE" + "web/libraries/billboard/package.json" + "web/libraries/billboard/dist/package.json" + ) + counter=0 + echo "Deleting unneeded files inside web/libraries/billboard" + for file in "${files[@]}" + do + if [[ -f $file ]]; then + echo "Deleting $file" + rm $file + counter=$((counter+1)) + fi + done + echo "$counter files were deleted" + +6. Add a "scripts" entry to your "composer.json" file as shown below. If +"scripts" already exists, you will need to do a little more to incorporate +the code below. + + "scripts": { + "clean-billboardjs": "chmod +x scripts/clean-billboardjs.sh && + ./scripts/clean-billboardjs.sh", + "post-install-cmd": [ + "@clean-billboardjs" + ], + "post-update-cmd": [ + "@clean-billboardjs" + ] + } + +7. Run the following command; you should find that new directories have been +created under "/libraries". + + composer require --prefer-dist billboardjs/billboard:3.10.3 d3/d3:7.8.5 diff --git a/web/modules/charts/modules/charts_billboard/charts_billboard.info.yml b/web/modules/charts/modules/charts_billboard/charts_billboard.info.yml new file mode 100644 index 00000000..38c22dab --- /dev/null +++ b/web/modules/charts/modules/charts_billboard/charts_billboard.info.yml @@ -0,0 +1,12 @@ +name: 'Billboard.js Charts' +type: module +description: 'Charts module integration with Billboard.js Charts.' +package: Charts +core_version_requirement: ^10.3 || ^11 +dependencies: + - 'charts:charts' + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/modules/charts_billboard/charts_billboard.install b/web/modules/charts/modules/charts_billboard/charts_billboard.install new file mode 100644 index 00000000..b7cddf34 --- /dev/null +++ b/web/modules/charts/modules/charts_billboard/charts_billboard.install @@ -0,0 +1,95 @@ +get('advanced.requirements.cdn') ?? FALSE; + + if (!$library_path) { + if ($cdn) { + $requirements['charts_billboard_js'] = [ + 'title' => t('Billboard.js Library'), + 'value' => t('Available through a CDN'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('You are using the Billboard.js library via a content delivery network. It is generally considered better practice to install the library files locally. Please see the README file inside charts_billboard for instructions to install the library.'), + ]; + } + else { + $requirements['charts_billboard_js'] = [ + 'title' => t('Billboard.js Library'), + 'value' => t('Not Installed'), + 'severity' => REQUIREMENT_ERROR, + 'description' => t('You are missing the Billboard.js library in your Drupal installation directory and you have opted not to use a CDN. Please either enable use of the CDN in the Chart Settings under the Advanced tab or see the README file inside charts_billboard for instructions to install the library.'), + ]; + } + } + else { + // If the library directory contains a demo directory, warn. + if (file_exists($library_path . '/src')) { + $requirements['charts_billboard_js'] = [ + 'title' => t('Billboard.js Library'), + 'value' => t('Installed'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('You have installed the Billboard.js library in your Drupal installation directory, but it contains additional files that are not required and may be harmful. Please delete everything except the files listed in charts_billboard.libraries.yml. We have included a suggested post-install/post-update script in the README file.'), + ]; + } + else { + $requirements['charts_billboard_js'] = [ + 'title' => t('Billboard.js Library'), + 'value' => t('Installed'), + 'severity' => REQUIREMENT_OK, + 'description' => t('You have installed the Billboard.js library in your Drupal installation directory.'), + ]; + } + } + break; + } + + return $requirements; +} + +/** + * Get the location of the Billboard.js library. + * + * @return string + * The location of the library, or FALSE if the library isn't installed. + */ +function charts_billboard_find_library() { + // The following logic is taken from libraries_get_libraries() + $searchdir = []; + + // Similar to 'modules' and 'themes' directories inside an installation + // profile, installation profiles may want to place libraries into a + // 'libraries' directory. + $searchdir[] = 'profiles/' . \Drupal::installProfile() . '/libraries'; + + // Always search libraries. + $searchdir[] = 'libraries'; + + // Also search sites//*. + $container = \Drupal::getContainer(); + $site_path = $container->getParameter('site.path'); + $site_path = explode('/', $site_path); + $site_name = $site_path[1]; + $searchdir[] = $site_name . '/libraries'; + + foreach ($searchdir as $dir) { + if (file_exists($dir . '/billboard/dist/billboard.min.js') || file_exists($dir . '/billboard/dist/billboard.js')) { + return $dir . '/billboard'; + } + } + + return FALSE; +} diff --git a/web/modules/charts/modules/charts_billboard/charts_billboard.libraries.yml b/web/modules/charts/modules/charts_billboard/charts_billboard.libraries.yml new file mode 100755 index 00000000..900e1428 --- /dev/null +++ b/web/modules/charts/modules/charts_billboard/charts_billboard.libraries.yml @@ -0,0 +1,36 @@ +charts_billboard: + version: 1.x + js: + js/charts_billboard.js: { weight: -1 } + dependencies: + - charts/global + - core/drupal + - core/once +d3: + remote: 'https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js' + version: 7.8.5 + license: + name: BSD + url: 'https://en.wikipedia.org/wiki/BSD_licenses' + gpl-compatible: false + cdn: + /libraries/d3/: https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/ + js: + /libraries/d3/d3.min.js: { minified: true } +billboard: + remote: 'https://naver.github.io/billboard.js/release/3.10.3/dist/billboard.min.js' + version: 3.10.3 + license: + name: MIT + url: 'https://opensource.org/licenses/MIT' + gpl-compatible: true + cdn: + /libraries/billboard/: https://naver.github.io/billboard.js/release/3.10.3/ + css: + theme: + /libraries/billboard/dist/billboard.min.css: { minified: true } + js: + /libraries/billboard/dist/billboard.min.js: { minified: true } + dependencies: + - charts_billboard/charts_billboard + - charts_billboard/d3 diff --git a/web/modules/charts/modules/charts_billboard/charts_billboard.module b/web/modules/charts/modules/charts_billboard/charts_billboard.module new file mode 100644 index 00000000..3101da69 --- /dev/null +++ b/web/modules/charts/modules/charts_billboard/charts_billboard.module @@ -0,0 +1,17 @@ +getEditable('charts_billboard.settings'); + if ($billboard_config) { + $billboard_config->delete(); + } +} diff --git a/web/modules/charts/modules/charts_billboard/composer.json b/web/modules/charts/modules/charts_billboard/composer.json new file mode 100755 index 00000000..a43a0bc4 --- /dev/null +++ b/web/modules/charts/modules/charts_billboard/composer.json @@ -0,0 +1,49 @@ +{ + "name": "drupal/charts_billboard", + "description": "Charts module integration with Billboard.js Charts.", + "type": "drupal-module", + "homepage": "http://drupal.org/project/charts", + "support": { + "issues": "https://www.drupal.org/project/issues/charts" + }, + "license": "GPL-2.0+", + "require": { + "billboardjs/billboard": "3.10.3", + "d3/d3": "7.8.5" + }, + "repositories": [ + { + "type": "package", + "package": { + "name": "billboardjs/billboard", + "version": "3.10.3", + "type": "drupal-library", + "extra": { + "installer-name": "billboard" + }, + "dist": { + "url": "https://github.com/naver/billboard.js/archive/3.10.3.zip", + "type": "zip" + } + } + }, + { + "type": "package", + "package": { + "name": "d3/d3", + "version": "7.8.5", + "type": "drupal-library", + "extra": { + "installer-name": "d3" + }, + "dist": { + "url": "https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + } + ] +} diff --git a/web/modules/charts/modules/charts_billboard/js/charts_billboard.js b/web/modules/charts/modules/charts_billboard/js/charts_billboard.js new file mode 100755 index 00000000..a35216ff --- /dev/null +++ b/web/modules/charts/modules/charts_billboard/js/charts_billboard.js @@ -0,0 +1,26 @@ +/** + * @file + * JavaScript integration between Billboard and Drupal. + */ +(function (Drupal, once) { + + 'use strict'; + + Drupal.behaviors.chartsBillboard = { + attach: function (context, settings) { + const contents = new Drupal.Charts.Contents(); + once('charts-billboard', '.charts-billboard', context).forEach(function (element) { + const config = contents.getData(element.id); + const title = config.title.text; + // If the title contains '\\n', convert it to a line break. + if (title.indexOf('\\n') !== -1) { + config.title.text = title.replace(/\\n/g, '\n'); + } + bb.generate(config); + if (element.nextElementSibling && element.nextElementSibling.hasAttribute('data-charts-debug-container')) { + element.nextElementSibling.querySelector('code').innerText = JSON.stringify(config, null, ' '); + } + }); + } + }; +}(Drupal, once)); diff --git a/web/modules/charts/modules/charts_billboard/src/Plugin/chart/Library/Billboard.php b/web/modules/charts/modules/charts_billboard/src/Plugin/chart/Library/Billboard.php new file mode 100755 index 00000000..4ca6a1fe --- /dev/null +++ b/web/modules/charts/modules/charts_billboard/src/Plugin/chart/Library/Billboard.php @@ -0,0 +1,454 @@ +elementInfo = $element_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('element_info') + ); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + $form['intro_text'] = [ + '#markup' => $this->t('This is a placeholder for Billboard.js-specific library options. If you would like to help build this out, please work from this issue.', [ + '@issue_link' => Url::fromUri('https://www.drupal.org/project/charts/issues/3046983')->toString(), + ]), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function preRender(array $element) { + // Populate chart settings. + $chart_definition = []; + + $chart_definition = $this->populateOptions($element, $chart_definition); + $chart_definition = $this->populateData($element, $chart_definition); + $chart_definition = $this->populateAxes($element, $chart_definition); + + if (!empty($element['#height']) || !empty($element['#width'])) { + $element['#attributes']['style'] = 'height:' . $element['#height'] . $element['#height_units'] . ';width:' . $element['#width'] . $element['#width_units'] . ';'; + } + + if (!isset($element['#id'])) { + $element['#id'] = Html::getUniqueId('chart-billboard'); + } + $chart_definition['bindto'] = '#' . $element['#id']; + + $element['#attached']['library'][] = 'charts_billboard/billboard'; + $element['#attributes']['class'][] = 'charts-billboard charts-bb'; + $element['#chart_definition'] = $chart_definition; + + return $element; + } + + /** + * Get the chart type. + * + * @param string $chart_type + * The chart type. + * @param bool $is_polar + * Whether the polar is checked. + * + * @return string + * The chart type. + */ + protected function getType($chart_type, $is_polar = FALSE) { + // If Polar is checked, then convert to Radar chart type. + if ($is_polar) { + $type = 'radar'; + } + else { + $type = $chart_type == 'column' ? 'bar' : $chart_type; + } + return $type; + } + + /** + * Get options. + * + * @param string $type + * The chart type. + * @param array $element + * The element. + * + * @return array + * The returned options. + */ + protected function getOptionsByType($type, array $element) { + $options = $this->getOptionsByCustomProperty($element, $type); + if ($type === 'bar') { + $options['width'] = $element['#width']; + } + + return $options; + } + + /** + * Get the options by custom property. + * + * @param array $element + * The element. + * @param string $type + * The chart type. + * + * @return array + * The return options. + */ + protected function getOptionsByCustomProperty(array $element, $type) { + $options = []; + $properties = Element::properties($element); + // Remove properties which are not related to this chart type. + $properties = array_filter($properties, function ($property) use ($type) { + $query = '#chart_' . $type . '_'; + return substr($property, 0, strlen($query)) === $query; + }); + foreach ($properties as $property) { + $query = '#chart_' . $type . '_'; + $option_key = substr($property, strlen($query), strlen($property)); + $options[$option_key] = $element[$property]; + } + return $options; + } + + /** + * Populate options. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateOptions(array $element, array $chart_definition) { + $type = $this->getType($element['#chart_type'], $element['#polar'] ?? FALSE); + $title = $element['#title'] ?? ''; + if (!empty($element['#subtitle'])) { + $title .= '\n' . $element['#subtitle']; + } + $chart_definition['title']['text'] = $title; + $chart_definition['legend']['show'] = !empty($element['#legend_position']); + if (!in_array($type, ['scatter', 'bubble'])) { + $chart_definition['axis']['x']['type'] = 'category'; + } + $chart_definition['data']['labels'] = (bool) $element['#data_labels']; + + if ($type === 'pie' || $type === 'donut') { + // Do nothing. + } + elseif ($type === 'gauge') { + $chart_definition['gauge']['min'] = $element['#gauge']['min']; + $chart_definition['gauge']['max'] = $element['#gauge']['max']; + $chart_definition['color']['pattern'] = [ + 'red', + 'yellow', + 'green', + ]; + $chart_definition['color']['threshold']['values'] = [ + $element['#gauge']['red_from'], + $element['#gauge']['yellow_from'], + $element['#gauge']['green_from'], + ]; + } + elseif ($type === 'line' || $type === 'spline') { + $chart_definition['point']['show'] = !empty($element['#data_markers']); + } + else { + /* + * Billboard does not use bar, so column must be used. Since 'column' + * is changed + * to 'bar' in getType(), we need to use the value from the element. + */ + if ($element['#chart_type'] === 'bar') { + $chart_definition['axis']['rotated'] = TRUE; + } + elseif ($element['#chart_type'] === 'column') { + $type = 'bar'; + $chart_definition['axis']['rotated'] = FALSE; + } + } + $chart_definition['data']['type'] = $type; + // Merge in chart raw options. + if (!empty($element['#raw_options'])) { + $chart_definition = NestedArray::mergeDeepArray([ + $chart_definition, + $element['#raw_options'], + ]); + } + + return $chart_definition; + } + + /** + * Populate axes. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateAxes(array $element, array $chart_definition) { + $children = Element::children($element); + foreach ($children as $child) { + $type = $element[$child]['#type']; + if ($type === 'chart_xaxis') { + $chart_definition['axis']['x']['label'] = $element[$child]['#title'] ?? ''; + $chart_type = $this->getType($element['#chart_type']); + $categories = $this->stripLabelTags($element[$child]['#labels']); + if (!in_array($chart_type, ['pie', 'donut'])) { + if ($chart_type === 'scatter' || $chart_type === 'bubble') { + // Do nothing. + } + else { + $chart_definition['data']['columns'][] = ['x']; + $chart_definition['data']['x'] = 'x'; + $categories_keys = array_keys($chart_definition['data']['columns']); + $categories_key = end($categories_keys); + foreach ($categories as $category) { + $chart_definition['data']['columns'][$categories_key][] = $category; + } + } + } + else { + $chart_definition['data']['columns'] = array_map(NULL, $categories, $chart_definition['data']['columns']); + } + } + if ($type === 'chart_yaxis') { + if (!empty($element[$child]['#opposite']) && $element[$child]['#opposite'] === TRUE) { + $chart_definition['axis']['y2']['show'] = TRUE; + $this->setLabelMinMax($chart_definition, 'y2', $element[$child]); + } + else { + $this->setLabelMinMax($chart_definition, 'y', $element[$child]); + } + } + } + + return $chart_definition; + } + + /** + * Set the label, min, and max. + * + * @param array $chart_definition + * The chart definition. + * @param string $axis + * The axis. + * @param array $element + * The element. + */ + private function setLabelMinMax(array &$chart_definition, string $axis, array $element): void { + $chart_definition['axis'][$axis]['label'] = $element['#title'] ?? ''; + if (!empty($element['#min'])) { + $chart_definition['axis'][$axis]['min'] = $element['#min']; + } + if (!empty($element['#max'])) { + $chart_definition['axis'][$axis]['max'] = $element['#max']; + } + } + + /** + * Populate data. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateData(array &$element, array $chart_definition) { + $type = $this->getType($element['#chart_type'], $element['#polar'] ?? FALSE); + $types = []; + $children = Element::children($element); + $y_axes = []; + foreach ($children as $child) { + $element_type = $element[$child]['#type']; + if ($element_type === 'chart_yaxis') { + $y_axes[] = $child; + } + } + $data_elements = array_filter($children, function ($child) use ($element) { + return $element[$child]['#type'] === 'chart_data'; + }); + + $columns = $chart_definition['data']['columns'] ?? []; + $column_keys = array_keys($columns); + $columns_key_start = $columns ? end($column_keys) + 1 : 0; + foreach ($data_elements as $key) { + $child_element = $element[$key]; + // Make sure defaults are loaded. + if (empty($child_element['#defaults_loaded'])) { + $child_element += $this->elementInfo->getInfo($child_element['#type']); + } + if ($child_element['#color'] && $type !== 'gauge') { + $chart_definition['color']['pattern'][] = $child_element['#color']; + } + if (!in_array($type, ['pie', 'donut'])) { + $series_title = isset($child_element['#title']) ? strip_tags($child_element['#title']) : ''; + $types[$series_title] = $child_element['#chart_type'] ? $this->getType($child_element['#chart_type'], $element['#polar'] ?? FALSE) : $type; + if (!in_array($type, ['scatter', 'bubble'])) { + $columns[$columns_key_start][] = $series_title; + foreach ($child_element['#data'] as $datum) { + if (gettype($datum) === 'array') { + if ($type === 'gauge') { + array_shift($datum); + } + $columns[$columns_key_start][] = array_map(function ($item) { + return isset($item) ? strip_tags($item) : NULL; + }, $datum); + } + else { + $columns[$columns_key_start][] = isset($datum) ? strip_tags($datum) : NULL; + } + } + } + else { + $row = []; + $row[$series_title][0] = $series_title; + $row[$series_title . '_x'][0] = $series_title . '_x'; + foreach ($child_element['#data'] as $datum) { + $row[$series_title][] = $datum[0]; + $row[$series_title . '_x'][] = $datum[1]; + } + $chart_definition['data']['xs'][$series_title] = $series_title . '_x'; + foreach ($row as $value) { + $columns[] = $value; + } + $columns = array_values($columns); + } + } + else { + foreach ($child_element['#data'] as $datum_index => $datum) { + if (!empty($datum['color'])) { + $chart_definition['color']['pattern'][$datum_index] = $datum['color']; + unset($datum['color']); + $datum = array_values($datum); + } + $columns[] = $datum; + } + + // Add colors for each segment. + foreach ($child_element['#grouping_colors'] ?? [] as $key => $colors_array) { + $color = reset($colors_array); + $chart_definition['color']['pattern'][$key] = $color; + } + } + + $columns_key_start++; + } + if ($element['#stacking']) { + $chart_definition['data']['groups'] = [array_keys($types)]; + } + $chart_definition['data']['types'] = $types; + $chart_definition['data']['columns'] = $columns; + + if (count($y_axes) >= 2) { + foreach ($columns as $index => $column) { + if ($index <= 1) { + $axis = ($index + 1) === 2 ? 2 : ''; + $chart_definition['data']['axes'][$column[0]] = 'y' . $axis; + } + } + } + + return $chart_definition; + } + + /** + * Strip tags from each item in an array. + * + * @param array $items + * The array. + * + * @return array + * Return the cleaned array. + */ + private function stripLabelTags(array $items): array { + if (empty($items)) { + return []; + } + $categories = []; + foreach ($items as $item) { + $categories[] = isset($item) ? strip_tags($item) : NULL; + } + + return $categories; + } + +} diff --git a/web/modules/charts/modules/charts_blocks/README.md b/web/modules/charts/modules/charts_blocks/README.md new file mode 100644 index 00000000..64044651 --- /dev/null +++ b/web/modules/charts/modules/charts_blocks/README.md @@ -0,0 +1,9 @@ +#Charts Blocks + +Use this module if you would like a chart added via Layout Builder or the +"Block layout" page. You will need to enable at least one charting provider +submodule before this module can be used. Data can be added manually or by +uploading a CSV file. + +If you create your chart using a View, you do not need this module - just add +a "Block" display to your View. diff --git a/web/modules/charts/modules/charts_blocks/charts_blocks.info.yml b/web/modules/charts/modules/charts_blocks/charts_blocks.info.yml new file mode 100644 index 00000000..cc29fd8c --- /dev/null +++ b/web/modules/charts/modules/charts_blocks/charts_blocks.info.yml @@ -0,0 +1,12 @@ +name: Charts Blocks +type: module +description: Create Charts blocks without the need for Views. +core_version_requirement: ^10.3 || ^11 +package: Charts +dependencies: + - drupal:charts + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/modules/charts_blocks/charts_blocks.module b/web/modules/charts/modules/charts_blocks/charts_blocks.module new file mode 100755 index 00000000..ec58fb0e --- /dev/null +++ b/web/modules/charts/modules/charts_blocks/charts_blocks.module @@ -0,0 +1,24 @@ +' . t('About') . ''; + $output .= '

' . t('Create Charts blocks without the need for Views.') . '

'; + return $output; + + default: + } +} diff --git a/web/modules/charts/modules/charts_blocks/src/Plugin/Block/ChartsBlock.php b/web/modules/charts/modules/charts_blocks/src/Plugin/Block/ChartsBlock.php new file mode 100755 index 00000000..a0d3e043 --- /dev/null +++ b/web/modules/charts/modules/charts_blocks/src/Plugin/Block/ChartsBlock.php @@ -0,0 +1,114 @@ +setConfiguration($configuration); + $this->configFactory = $config_factory; + $this->uuidService = $uuidService; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('config.factory'), + $container->get('uuid') + ); + } + + /** + * {@inheritdoc} + */ + public function blockForm($form, FormStateInterface $form_state) { + parent::blockForm($form, $form_state); + + $chart_block_configurations = !empty($this->configuration['chart']) ? $this->configuration['chart'] : []; + + // Merge the charts default settings with this block's configuration. + $charts_settings = $this->configFactory->get('charts.settings'); + $charts_default_settings = $charts_settings->get('charts_default_settings') ?? []; + $defaults = NestedArray::mergeDeep($charts_default_settings, $chart_block_configurations); + + $form['chart'] = [ + '#type' => 'details', + '#title' => $this->t('Chart configurations'), + '#open' => TRUE, + ]; + + $form['chart']['settings'] = [ + '#type' => 'charts_settings', + '#used_in' => 'basic_form', + '#required' => TRUE, + '#series' => TRUE, + '#default_value' => $defaults, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function blockSubmit($form, FormStateInterface $form_state) { + $this->configuration['chart'] = $form_state->getValue(['chart', 'settings']); + } + + /** + * {@inheritdoc} + */ + public function build() { + $chart_settings = !empty($this->configuration['chart']) ? $this->configuration['chart'] : []; + + // Creates a UUID for the chart ID. + $chart_id = 'charts_block__' . $this->configuration['id']; + $id = 'chart-' . $this->uuidService->generate(); + $build = Chart::buildElement($chart_settings, $chart_id); + $build['#id'] = $id; + $build['#chart_id'] = $chart_id; + + return $build; + } + +} diff --git a/web/modules/charts/modules/charts_c3/README.md b/web/modules/charts/modules/charts_c3/README.md new file mode 100755 index 00000000..a10e64e1 --- /dev/null +++ b/web/modules/charts/modules/charts_c3/README.md @@ -0,0 +1,137 @@ +#Installation Using Composer (recommended) + +If you use Composer to manage dependencies, edit your site's "composer.json" +file as follows. + +1. Run the following command to ensure that you have the "composer/installers" + package installed. This package facilitates the installation of packages into + directories other than "/vendor" (e.g. "/libraries") using Composer. + + composer require --prefer-dist composer/installers + +2. Add the following to the "installer-paths" section of "composer.json": + + "libraries/{$name}": ["type:drupal-library"], + +3. Add the following to the "repositories" section of "composer.json": + + { + "type": "package", + "package": { + "name": "c3js/c3", + "version": "v0.7.20", + "type": "drupal-library", + "extra": { + "installer-name": "c3" + }, + "dist": { + "url": "https://github.com/c3js/c3/archive/v0.7.20.zip", + "type": "zip" + } + } + }, + { + "type": "package", + "package": { + "name": "d3/d3", + "version": "v4.9.1", + "type": "drupal-library", + "extra": { + "installer-name": "d3" + }, + "dist": { + "url": "https://github.com/d3/d3/archive/v4.9.1.zip", + "type": "zip" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + } + +4. This and the following step is optional but recommended. The reason for + them is that when installing the C3.js package with Composer, + additional files are added into the library directory. These files are not + necessary and can be potentially harmful to your site, so it's best to remove + them. So: create a new directory in your project root called "scripts". +5. Inside that directory, create a new file called "clean-c3js.sh" and + paste the following into it: + + #!/usr/bin/env bash + set -eu + declare -a directories=( + "web/libraries/c3/.circleci" + "web/libraries/c3/.github" + "web/libraries/c3/data" + "web/libraries/c3/design" + "web/libraries/c3/docs" + "web/libraries/c3/extensions" + "web/libraries/c3/htdocs" + "web/libraries/c3/spec" + "web/libraries/c3/src" + ) + counter=0 + echo "Deleting unneeded directories inside web/libraries/c3" + for directory in "${directories[@]}" + do + if [ -d $directory ]; then + echo "Deleting $directory" + rm -rf $directory + counter=$((counter+1)) + fi + done + echo "$counter folders were deleted" + declare -a files=( + "web/libraries/c3/CONTRIBUTING.md" + "web/libraries/c3/README.md" + "web/libraries/c3/LICENSE" + "web/libraries/c3/package.json" + "web/libraries/c3/.bmp.yml" + "web/libraries/c3/.editorconfig" + "web/libraries/c3/.gitignore" + "web/libraries/c3/.jshintrc" + "web/libraries/c3/.prettierrc.json" + "web/libraries/c3/.travis.yml" + "web/libraries/c3/bower.json" + "web/libraries/c3/codecov.yml" + "web/libraries/c3/component.json" + "web/libraries/c3/config.rb" + "web/libraries/c3/Gemfile" + "web/libraries/c3/Gemfile.lock" + "web/libraries/c3/karma.conf.js" + "web/libraries/c3/MAINTAINANCE.md" + "web/libraries/c3/rollup.config.js" + "web/libraries/c3/tsconfig.json" + "web/libraries/c3/yarn.lock" + ) + counter=0 + echo "Deleting unneeded files inside web/libraries/c3" + for file in "${files[@]}" + do + if [[ -f $file ]]; then + echo "Deleting $file" + rm $file + counter=$((counter+1)) + fi + done + echo "$counter files were deleted" + +6. Add a "scripts" entry to your "composer.json" file as shown below. If + "scripts" already exists, you will need to do a little more to incorporate + the code below. + + "scripts": { + "clean-c3js": "chmod +x scripts/clean-c3js.sh && + ./scripts/clean-c3js.sh", + "post-install-cmd": [ + "@clean-c3js" + ], + "post-update-cmd": [ + "@clean-c3js" + ] + } + +7. Run the following command; you should find that new directories have been + created under "/libraries". + + composer require --prefer-dist c3js/c3:0.7.20 d3/d3:4.9.1 diff --git a/web/modules/charts/modules/charts_c3/charts_c3.info.yml b/web/modules/charts/modules/charts_c3/charts_c3.info.yml new file mode 100644 index 00000000..4f318cab --- /dev/null +++ b/web/modules/charts/modules/charts_c3/charts_c3.info.yml @@ -0,0 +1,12 @@ +name: 'C3.js Charts' +type: module +description: 'Charts module integration with C3.js Charts.' +package: Charts +core_version_requirement: ^10.3 || ^11 +dependencies: + - 'charts:charts' + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/modules/charts_c3/charts_c3.install b/web/modules/charts/modules/charts_c3/charts_c3.install new file mode 100644 index 00000000..5d8f03ba --- /dev/null +++ b/web/modules/charts/modules/charts_c3/charts_c3.install @@ -0,0 +1,95 @@ +get('advanced.requirements.cdn') ?? FALSE; + + if (!$library_path) { + if ($cdn) { + $requirements['charts_c3_js'] = [ + 'title' => t('C3.js Library'), + 'value' => t('Available through a CDN'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('You are using the C3.js library via a content delivery network. It is generally considered better practice to install the library files locally. Please see the README file inside charts_c3 for instructions to install the library.'), + ]; + } + else { + $requirements['charts_c3_js'] = [ + 'title' => t('C3.js Library'), + 'value' => t('Not Installed'), + 'severity' => REQUIREMENT_ERROR, + 'description' => t('You are missing the C3.js library in your Drupal installation directory and you have opted not to use a CDN. Please either enable use of the CDN in the Chart Settings under the Advanced tab or see the README file inside charts_c3 for instructions to install the library.'), + ]; + } + } + else { + // If the library directory contains a demo directory, warn. + if (file_exists($library_path . '/src')) { + $requirements['charts_c3_js'] = [ + 'title' => t('C3.js Library'), + 'value' => t('Installed'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('You have installed the C3.js library in your Drupal installation directory, but it contains additional files that are not required and may be harmful. Please delete everything except the files listed in charts_c3.libraries.yml. We have included a suggested post-install/post-update script in the README file.'), + ]; + } + else { + $requirements['charts_c3_js'] = [ + 'title' => t('C3.js Library'), + 'value' => t('Installed'), + 'severity' => REQUIREMENT_OK, + 'description' => t('You have installed the C3.js library in your Drupal installation directory.'), + ]; + } + } + break; + } + + return $requirements; +} + +/** + * Get the location of the C3.js library. + * + * @return string + * The location of the library, or FALSE if the library isn't installed. + */ +function charts_c3_find_library() { + // The following logic is taken from libraries_get_libraries() + $searchdir = []; + + // Similar to 'modules' and 'themes' directories inside an installation + // profile, installation profiles may want to place libraries into a + // 'libraries' directory. + $searchdir[] = 'profiles/' . \Drupal::installProfile() . '/libraries'; + + // Always search libraries. + $searchdir[] = 'libraries'; + + // Also search sites//*. + $container = \Drupal::getContainer(); + $site_path = $container->getParameter('site.path'); + $site_path = explode('/', $site_path); + $site_name = $site_path[1]; + $searchdir[] = $site_name . '/libraries'; + + foreach ($searchdir as $dir) { + if (file_exists($dir . '/c3/c3.min.js') || file_exists($dir . '/c3/c3.js')) { + return $dir . '/c3'; + } + } + + return FALSE; +} diff --git a/web/modules/charts/modules/charts_c3/charts_c3.libraries.yml b/web/modules/charts/modules/charts_c3/charts_c3.libraries.yml new file mode 100755 index 00000000..5f5714c0 --- /dev/null +++ b/web/modules/charts/modules/charts_c3/charts_c3.libraries.yml @@ -0,0 +1,36 @@ +charts_c3: + version: 1.x + js: + js/charts_c3.js: { weight: -1 } + dependencies: + - charts/global + - core/drupal + - core/once +d3: + remote: 'https://cdnjs.cloudflare.com/ajax/libs/d3/5.16.0/d3.min.js' + version: 5.16.0 + license: + name: BSD + url: 'https://en.wikipedia.org/wiki/BSD_licenses' + gpl-compatible: false + cdn: + /libraries/d3/: https://cdnjs.cloudflare.com/ajax/libs/d3/5.16.0/ + js: + /libraries/d3/d3.min.js: { } +c3: + remote: 'https://cdnjs.cloudflare.com/ajax/libs/c3/0.7.20/c3.min.js' + version: 0.7.20 + license: + name: MIT + url: 'https://opensource.org/licenses/MIT' + gpl-compatible: true + cdn: + /libraries/c3/: https://cdnjs.cloudflare.com/ajax/libs/c3/0.7.20/ + css: + theme: + /libraries/c3/c3.min.css: { } + js: + /libraries/c3/c3.min.js: { } + dependencies: + - charts_c3/charts_c3 + - charts_c3/d3 diff --git a/web/modules/charts/modules/charts_c3/charts_c3.module b/web/modules/charts/modules/charts_c3/charts_c3.module new file mode 100644 index 00000000..7b83228a --- /dev/null +++ b/web/modules/charts/modules/charts_c3/charts_c3.module @@ -0,0 +1,17 @@ +getEditable('charts_c3.settings'); + if ($c3_config) { + $c3_config->delete(); + } +} diff --git a/web/modules/charts/modules/charts_c3/composer.json b/web/modules/charts/modules/charts_c3/composer.json new file mode 100755 index 00000000..c76e596b --- /dev/null +++ b/web/modules/charts/modules/charts_c3/composer.json @@ -0,0 +1,49 @@ +{ + "name": "drupal/charts_c3", + "description": "Charts module integration with C3 Charts.", + "type": "drupal-module", + "homepage": "http://drupal.org/project/charts", + "support": { + "issues": "https://www.drupal.org/project/issues/charts" + }, + "license": "GPL-2.0+", + "require": { + "c3js/c3": "0.7.20", + "d3/d3": "4.9.1" + }, + "repositories": [ + { + "type": "package", + "package": { + "name": "c3js/c3", + "version": "v0.7.20", + "type": "drupal-library", + "extra": { + "installer-name": "c3" + }, + "dist": { + "url": "https://github.com/c3js/c3/archive/v0.7.20.zip", + "type": "zip" + } + } + }, + { + "type": "package", + "package": { + "name": "d3/d3", + "version": "v4.9.1", + "type": "drupal-library", + "extra": { + "installer-name": "d3" + }, + "dist": { + "url": "https://github.com/d3/d3/archive/v4.9.1.zip", + "type": "zip" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + } + ] +} diff --git a/web/modules/charts/modules/charts_c3/js/charts_c3.js b/web/modules/charts/modules/charts_c3/js/charts_c3.js new file mode 100755 index 00000000..189f4358 --- /dev/null +++ b/web/modules/charts/modules/charts_c3/js/charts_c3.js @@ -0,0 +1,21 @@ +/** + * @file + * JavaScript's integration between C3 and Drupal. + */ +(function (Drupal, once) { + + 'use strict'; + + Drupal.behaviors.chartsC3 = { + attach: function (context, settings) { + const contents = new Drupal.Charts.Contents(); + once('charts-c3', '.charts-c3', context).forEach(function (element) { + const config = contents.getData(element.id); + c3.generate(config); + if (element.nextElementSibling && element.nextElementSibling.hasAttribute('data-charts-debug-container')) { + element.nextElementSibling.querySelector('code').innerText = JSON.stringify(config, null, ' '); + } + }); + } + }; +}(Drupal, once)); diff --git a/web/modules/charts/modules/charts_c3/src/Plugin/chart/Library/C3.php b/web/modules/charts/modules/charts_c3/src/Plugin/chart/Library/C3.php new file mode 100755 index 00000000..2609c373 --- /dev/null +++ b/web/modules/charts/modules/charts_c3/src/Plugin/chart/Library/C3.php @@ -0,0 +1,454 @@ +elementInfo = $element_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('element_info') + ); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + $form['intro_text'] = [ + '#markup' => $this->t('

This is a placeholder for C3.js-specific library options. If you would like to help build this out, please work from this issue.

', [ + '@issue_link' => Url::fromUri('https://www.drupal.org/project/charts/issues/3046982')->toString(), + ]), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function preRender(array $element) { + // Populate chart settings. + $chart_definition = []; + + $chart_definition = $this->populateOptions($element, $chart_definition); + $chart_definition = $this->populateData($element, $chart_definition); + $chart_definition = $this->populateAxes($element, $chart_definition); + + if (!empty($element['#height']) || !empty($element['#width'])) { + $element['#attributes']['style'] = 'height:' . $element['#height'] . $element['#height_units'] . ';width:' . $element['#width'] . $element['#width_units'] . ';'; + } + + if (!isset($element['#id'])) { + $element['#id'] = Html::getUniqueId('chart-c3'); + } + $chart_definition['bindto'] = '#' . $element['#id']; + + $element['#attached']['library'][] = 'charts_c3/c3'; + $element['#attributes']['class'][] = 'charts-c3'; + $element['#chart_definition'] = $chart_definition; + + return $element; + } + + /** + * The chart type. + * + * @param string $chart_type + * The chart type. + * @param bool $is_polar + * Whether polar is checked. + * + * @return string + * Return the type. + */ + protected function getType($chart_type, $is_polar = FALSE) { + // If Polar is checked, then convert to Radar chart type. + if ($is_polar) { + $type = 'radar'; + } + else { + $type = $chart_type == 'column' ? 'bar' : $chart_type; + } + return $type; + } + + /** + * Get options by type. + * + * @param string $type + * The chart type. + * @param array $element + * The element. + * + * @return array + * Return options. + */ + protected function getOptionsByType($type, array $element) { + $options = $this->getOptionsByCustomProperty($element, $type); + if ($type === 'bar') { + $options['width'] = $element['#width']; + } + + return $options; + } + + /** + * Get options by custom property. + * + * @param array $element + * The element. + * @param string $type + * The chart type. + * + * @return array + * Return options. + */ + protected function getOptionsByCustomProperty(array $element, $type) { + $options = []; + $properties = Element::properties($element); + // Remove properties which are not related to this chart type. + $properties = array_filter($properties, function ($property) use ($type) { + $query = '#chart_' . $type . '_'; + return substr($property, 0, strlen($query)) === $query; + }); + foreach ($properties as $property) { + $query = '#chart_' . $type . '_'; + $option_key = substr($property, strlen($query), strlen($property)); + $options[$option_key] = $element[$property]; + } + return $options; + } + + /** + * Populate options. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateOptions(array $element, array $chart_definition) { + $type = $this->getType($element['#chart_type']); + $title = $element['#title'] ?? ''; + if (!empty($element['#subtitle'])) { + $title .= ': ' . $element['#subtitle']; + } + $chart_definition['title']['text'] = $title; + $chart_definition['legend']['show'] = !empty($element['#legend_position']); + if (!in_array($type, ['scatter', 'bubble'])) { + $chart_definition['axis']['x']['type'] = 'category'; + } + $chart_definition['data']['labels'] = (bool) $element['#data_labels']; + + if ($type === 'pie' || $type === 'donut') { + + } + elseif ($type === 'gauge') { + $chart_definition['gauge']['min'] = $element['#gauge']['min']; + $chart_definition['gauge']['max'] = $element['#gauge']['max']; + $chart_definition['color']['pattern'] = [ + 'red', + 'yellow', + 'green', + ]; + $chart_definition['color']['threshold']['values'] = [ + $element['#gauge']['red_from'], + $element['#gauge']['yellow_from'], + $element['#gauge']['green_from'], + ]; + } + elseif ($type === 'line' || $type === 'spline') { + $chart_definition['point']['show'] = !empty($element['#data_markers']); + } + else { + /* + * Billboard does not use bar, so column must be used. Since 'column' + * is changed + * to 'bar' in getType(), we need to use the value from the element. + */ + if ($element['#chart_type'] === 'bar') { + $chart_definition['axis']['rotated'] = TRUE; + } + elseif ($element['#chart_type'] === 'column') { + $type = 'bar'; + $chart_definition['axis']['rotated'] = FALSE; + } + } + $chart_definition['data']['type'] = $type; + // Merge in chart raw options. + if (!empty($element['#raw_options'])) { + $chart_definition = NestedArray::mergeDeepArray([ + $chart_definition, + $element['#raw_options'], + ]); + } + + return $chart_definition; + } + + /** + * Populate axes. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return chart definition. + */ + private function populateAxes(array $element, array $chart_definition) { + $children = Element::children($element); + foreach ($children as $child) { + $type = $element[$child]['#type']; + if ($type === 'chart_xaxis') { + $chart_definition['axis']['x']['label'] = $element[$child]['#title'] ?? ''; + $chart_type = $this->getType($element['#chart_type']); + $categories = $this->stripLabelTags($element[$child]['#labels']); + if (!in_array($chart_type, ['pie', 'donut'])) { + if ($chart_type === 'scatter' || $chart_type === 'bubble') { + // Scatter is not supported: https://github.com/c3js/c3/issues/1902 + } + else { + $chart_definition['data']['columns'][] = ['x']; + $chart_definition['data']['x'] = 'x'; + $categories_keys = array_keys($chart_definition['data']['columns']); + $categories_key = end($categories_keys); + foreach ($categories as $category) { + $chart_definition['data']['columns'][$categories_key][] = $category; + } + } + } + else { + $chart_definition['data']['columns'] = array_map(NULL, $categories, $chart_definition['data']['columns']); + } + } + if ($type === 'chart_yaxis') { + if (!empty($element[$child]['#opposite']) && $element[$child]['#opposite'] === TRUE) { + $chart_definition['axis']['y2']['show'] = TRUE; + $this->setLabelMinMax($chart_definition, 'y2', $element[$child]); + } + else { + $this->setLabelMinMax($chart_definition, 'y', $element[$child]); + } + } + } + + return $chart_definition; + } + + /** + * Set the label, min, and max. + * + * @param array $chart_definition + * The chart definition. + * @param string $axis + * The axis. + * @param array $element + * The element. + */ + private function setLabelMinMax(array &$chart_definition, string $axis, array $element): void { + $chart_definition['axis'][$axis]['label'] = $element['#title'] ?? ''; + if (!empty($element['#min'])) { + $chart_definition['axis'][$axis]['min'] = $element['#min']; + } + if (!empty($element['#max'])) { + $chart_definition['axis'][$axis]['max'] = $element['#max']; + } + } + + /** + * Populate data. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateData(array &$element, array $chart_definition) { + $type = $this->getType($element['#chart_type']); + $types = []; + $children = Element::children($element); + $y_axes = []; + foreach ($children as $child) { + $element_type = $element[$child]['#type']; + if ($element_type === 'chart_yaxis') { + $y_axes[] = $child; + } + } + $data_elements = array_filter($children, function ($child) use ($element) { + return $element[$child]['#type'] === 'chart_data'; + }); + + $columns = $chart_definition['data']['columns'] ?? []; + $column_keys = array_keys($columns); + $columns_key_start = $columns ? end($column_keys) + 1 : 0; + foreach ($data_elements as $key) { + $child_element = $element[$key]; + // Make sure defaults are loaded. + if (empty($child_element['#defaults_loaded'])) { + $child_element += $this->elementInfo->getInfo($child_element['#type']); + } + if ($child_element['#color'] && $type !== 'gauge') { + $chart_definition['color']['pattern'][] = $child_element['#color']; + } + if (!in_array($type, ['pie', 'donut'])) { + $series_title = isset($child_element['#title']) ? strip_tags($child_element['#title']) : ''; + $types[$series_title] = $child_element['#chart_type'] ? $this->getType($child_element['#chart_type']) : $type; + if (!in_array($type, ['scatter', 'bubble'])) { + $columns[$columns_key_start][] = $series_title; + foreach ($child_element['#data'] as $datum) { + if (gettype($datum) === 'array') { + if ($type === 'gauge') { + array_shift($datum); + } + $columns[$columns_key_start][] = array_map(function ($item) { + return isset($item) ? strip_tags($item) : NULL; + }, $datum); + } + else { + $columns[$columns_key_start][] = isset($datum) ? strip_tags($datum) : NULL; + } + } + } + else { + $row = []; + $row[$series_title][0] = $series_title; + $row[$series_title . '_x'][0] = $series_title . '_x'; + foreach ($child_element['#data'] as $datum) { + $row[$series_title][] = $datum[0]; + $row[$series_title . '_x'][] = $datum[1]; + } + $chart_definition['data']['xs'][$series_title] = $series_title . '_x'; + foreach ($row as $value) { + $columns[] = $value; + } + $columns = array_values($columns); + } + } + else { + foreach ($child_element['#data'] as $datum_index => $datum) { + if (!empty($datum['color'])) { + $chart_definition['color']['pattern'][$datum_index] = $datum['color']; + unset($datum['color']); + $datum = array_values($datum); + } + $columns[] = $datum; + } + + // Add colors for each segment. + foreach ($child_element['#grouping_colors'] ?? [] as $key => $colors_array) { + $color = reset($colors_array); + $chart_definition['color']['pattern'][$key] = $color; + } + } + + $columns_key_start++; + } + if ($element['#stacking']) { + $chart_definition['data']['groups'] = [array_keys($types)]; + } + $chart_definition['data']['types'] = $types; + $chart_definition['data']['columns'] = $columns; + + if (count($y_axes) >= 2) { + foreach ($columns as $index => $column) { + if ($index <= 1) { + $axis = ($index + 1) === 2 ? 2 : ''; + $chart_definition['data']['axes'][$column[0]] = 'y' . $axis; + } + } + } + + return $chart_definition; + } + + /** + * Strip tags from each item in an array. + * + * @param array $items + * The array. + * + * @return array + * Return the cleaned array. + */ + private function stripLabelTags(array $items): array { + if (empty($items)) { + return []; + } + $categories = []; + foreach ($items as $item) { + $categories[] = isset($item) ? strip_tags($item) : NULL; + } + + return $categories; + } + +} diff --git a/web/modules/charts/modules/charts_chartjs/README.md b/web/modules/charts/modules/charts_chartjs/README.md new file mode 100755 index 00000000..b4f05cc6 --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/README.md @@ -0,0 +1,158 @@ +#Installation Using Composer (recommended) + +If you use Composer to manage dependencies, edit your site's "composer.json" +file as follows. + +1. Add the asset-packagist composer repository to "composer.json". +This allows installing packages (like Chart.js) that are published on NPM. + + "asset-packagist": { + "type": "composer", + "url": "https://asset-packagist.org" + }, + +You may need to add it in your composer.json file like this (second item in +the array): + + "repositories": [ + { + "type": "composer", + "url": "https://packages.drupal.org/8" + }, + { + "type": "composer", + "url": "https://asset-packagist.org" + }, + ], + +2. Run the following command to ensure that you have the +"oomphinc/composer-installers-extender" package installed. This package +facilitates the installation of any package into directories other than the +default "/vendor" (e.g. "/libraries") using Composer. + + composer require --prefer-dist oomphinc/composer-installers-extender + +3. Configure composer to install the Chart.js dependencies into "/libraries" +by adding the following "installer-types" and "installer-paths" to the "extra" +section of "composer.json". If you are not using the "web" directory, then +remove "web/" from the lines below: + +"extra": { + "installer-types": ["npm-asset"], + "installer-paths": { + "web/libraries/chart.js": ["npm-asset/chart.js"], + "web/libraries/chartjs-adapter-date-fns": [ + "npm-asset/chartjs-adapter-date-fns" + ], + "web/libraries/chartjs-plugin-datalabels": [ + "npm-asset/chartjs-plugin-datalabels" + ], + }, +} + +NOTE: If this isn't working, try instead adding: +"extra": { + "installer-types": ["npm-asset"], + "installer-paths": { + ... + "web/libraries/{$name}": ["type:drupal-library", "vendor:npm-asset"] + }, +} + +4. This and the following step is optional but recommended. The reason for +them is that when installing the Chart.js package with Composer, +additional files are added into the library directory. These files are not +necessary and can be potentially harmful to your site, so it's best to remove +them. So: create a new directory in your project root called "scripts". + +5. Inside that directory, create a new file called "clean-chartjs.sh" and + paste the following into it: + + #!/usr/bin/env bash + set -eu + declare -a directories=( + "web/libraries/chart.js/auto" + "web/libraries/chart.js/helpers" + "web/libraries/chart.js/types" + "web/libraries/chart.js/dist/chunks" + "web/libraries/chart.js/dist/docs" + "web/libraries/chart.js/dist/controllers" + "web/libraries/chart.js/dist/core" + "web/libraries/chart.js/dist/elements" + "web/libraries/chart.js/dist/helpers" + "web/libraries/chart.js/dist/platform" + "web/libraries/chart.js/dist/plugins" + "web/libraries/chart.js/dist/scales" + "web/libraries/chart.js/dist/types" + "web/libraries/chartjs-plugin-datalabels/types" + ) + counter=0 + echo "Deleting unneeded directories inside web/libraries/chartjs" + for directory in "${directories[@]}" + do + if [ -d $directory ]; then + echo "Deleting $directory" + rm -rf $directory + counter=$((counter+1)) + fi + done + echo "$counter folders were deleted" + declare -a files=( + "web/libraries/chart.js/README.md" + "web/libraries/chart.js/LICENSE.md" + "web/libraries/chart.js/package.json" + "web/libraries/chart.js/dist/helpers.esm.js" + "web/libraries/chart.js/dist/helpers.mjs" + "web/libraries/chart.js/dist/chart.mjs" + "web/libraries/chart.js/dist/chart.esm.js" + "web/libraries/chart.js/dist/chart.cjs" + "web/libraries/chart.js/dist/chart.cjs.map" + "web/libraries/chart.js/dist/helpers.js" + "web/libraries/chart.js/dist/helpers.js.map" + "web/libraries/chart.js/dist/helpers.cjs" + "web/libraries/chart.js/dist/helpers.cjs.map" + "web/libraries/chart.js/dist/index.d.ts" + "web/libraries/chart.js/dist/index.umd.d.ts" + "web/libraries/chart.js/dist/types.d.ts" + "web/libraries/chartjs-adapter-date-fns/README.md" + "web/libraries/chartjs-adapter-date-fns/LICENSE.md" + "web/libraries/chartjs-adapter-date-fns/package.json" + "web/libraries/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.esm.js" + "web/libraries/chartjs-plugin-datalabels/README.md" + "web/libraries/chartjs-plugin-datalabels/LICENSE.md" + "web/libraries/chartjs-plugin-datalabels/package.json" + "web/libraries/chartjs-plugin-datalabels/bower.json" + ) + counter=0 + echo "Deleting unneeded files inside web/libraries/chartjs" + for file in "${files[@]}" + do + if [[ -f $file ]]; then + echo "Deleting $file" + rm $file + counter=$((counter+1)) + fi + done + echo "$counter files were deleted" + +6. Add a "scripts" entry to your "composer.json" file as shown below. If + "scripts" already exists, you will need to do a little more to incorporate + the code below. + + "scripts": { + "clean-chartjs": "chmod +x scripts/clean-chartjs.sh && + ./scripts/clean-chartjs.sh", + "post-install-cmd": [ + "@clean-chartjs" + ], + "post-update-cmd": [ + "@clean-chartjs" + ] + } + +7. Run the following command; you should find that new directories have been + created under "/libraries". + + composer require --prefer-dist npm-asset/chart.js:^4.4 + npm-asset/chartjs-adapter-date-fns:^3.0 + npm-asset/chartjs-plugin-datalabels:^2.0 diff --git a/web/modules/charts/modules/charts_chartjs/charts_chartjs.charts_types.yml b/web/modules/charts/modules/charts_chartjs/charts_chartjs.charts_types.yml new file mode 100644 index 00000000..68f79621 --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/charts_chartjs.charts_types.yml @@ -0,0 +1,5 @@ +polarArea: + label: 'Polar Area' + axis: 'y_only' + axis_inverted: false + stacking: false diff --git a/web/modules/charts/modules/charts_chartjs/charts_chartjs.info.yml b/web/modules/charts/modules/charts_chartjs/charts_chartjs.info.yml new file mode 100644 index 00000000..7f816aef --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/charts_chartjs.info.yml @@ -0,0 +1,12 @@ +name: 'Chart.js Charts' +type: module +description: 'Charts module integration with Chart.js.' +package: Charts +core_version_requirement: ^10.3 || ^11 +dependencies: + - 'charts:charts' + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/modules/charts_chartjs/charts_chartjs.install b/web/modules/charts/modules/charts_chartjs/charts_chartjs.install new file mode 100644 index 00000000..d339c068 --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/charts_chartjs.install @@ -0,0 +1,95 @@ +get('advanced.requirements.cdn') ?? FALSE; + + if (!$library_path) { + if ($cdn) { + $requirements['charts_chartjs_js'] = [ + 'title' => t('Chart.js Library'), + 'value' => t('Available through a CDN'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('You are using the Chart.js library via a content delivery network. It is generally considered better practice to install the library files locally. Please see the README file inside charts_chartjs for instructions to install the library.'), + ]; + } + else { + $requirements['charts_chartjs_js'] = [ + 'title' => t('Chart.js Library'), + 'value' => t('Not Installed'), + 'severity' => REQUIREMENT_ERROR, + 'description' => t('You are missing the Chart.js library in your Drupal installation directory and you have opted not to use a CDN. Please either enable use of the CDN in the Chart Settings under the Advanced tab or see the README file inside charts_chartjs for instructions to install the library.'), + ]; + } + } + else { + // If the library directory contains "auto", provide a warning. + if (file_exists($library_path . '/auto')) { + $requirements['charts_chartjs_js'] = [ + 'title' => t('Chart.js Library'), + 'value' => t('Installed'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('You have installed the Chart.js library in your Drupal installation directory, but it contains additional files that are not required and may be harmful. Please delete everything except the files listed in charts_chartjs.libraries.yml. We have included a suggested post-install/post-update script in the README file.'), + ]; + } + else { + $requirements['charts_chartjs_js'] = [ + 'title' => t('Chart.js Library'), + 'value' => t('Installed'), + 'severity' => REQUIREMENT_OK, + 'description' => t('You have installed the Chart.js library in your Drupal installation directory.'), + ]; + } + } + break; + } + + return $requirements; +} + +/** + * Get the location of the Chart.js library. + * + * @return string + * The location of the library, or FALSE if the library isn't installed. + */ +function charts_chartjs_find_library() { + // The following logic is taken from libraries_get_libraries() + $searchdir = []; + + // Similar to 'modules' and 'themes' directories inside an installation + // profile, installation profiles may want to place libraries into a + // 'libraries' directory. + $searchdir[] = 'profiles/' . \Drupal::installProfile() . '/libraries'; + + // Always search libraries. + $searchdir[] = 'libraries'; + + // Also search sites//*. + $container = \Drupal::getContainer(); + $site_path = $container->getParameter('site.path'); + $site_path = explode('/', $site_path); + $site_name = $site_path[1]; + $searchdir[] = $site_name . '/libraries'; + + foreach ($searchdir as $dir) { + if (file_exists($dir . '/chart.js/dist/chart.js') || file_exists($dir . '/chart.js/dist/chart.umd.js')) { + return $dir . '/chartjs'; + } + } + + return FALSE; +} diff --git a/web/modules/charts/modules/charts_chartjs/charts_chartjs.libraries.yml b/web/modules/charts/modules/charts_chartjs/charts_chartjs.libraries.yml new file mode 100755 index 00000000..f1a784fb --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/charts_chartjs.libraries.yml @@ -0,0 +1,26 @@ +charts_chartjs: + remote: 'https://github.com/chartjs/Chart.js' + version: VERSION + license: + name: 'Apache 2.0' + url: 'https://www.apache.org/licenses/LICENSE-2.0' + gpl-compatible: false + # @todo + cdn: + /libraries/chart.js/: https://unpkg.com/chart.js@4.4.0/ + /libraries/chartjs-adapter-date-fns/: https://unpkg.com/chartjs-adapter-date-fns@3.0.0/ + /libraries/chartjs-plugin-datalabels/: https://unpkg.com/chartjs-plugin-datalabels@2.0.0/ + js: + /libraries/chart.js/dist/chart.umd.js: { } + /libraries/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.js: { } + /libraries/chartjs-plugin-datalabels/dist/chartjs-plugin-datalabels.js: { } + dependencies: + - core/drupal + - core/once +chartjs: + version: 1.x + js: + js/charts_chartjs.js: { weight: -1 } + dependencies: + - charts/global + - charts_chartjs/charts_chartjs diff --git a/web/modules/charts/modules/charts_chartjs/charts_chartjs.module b/web/modules/charts/modules/charts_chartjs/charts_chartjs.module new file mode 100644 index 00000000..651aaee9 --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/charts_chartjs.module @@ -0,0 +1,32 @@ +getEditable('charts_chartjs.settings') : NULL; + if (!$is_config || !$chartjs_config || empty($new_settings['library']) || $new_settings['library'] !== 'chartjs') { + if ($chartjs_config) { + $chartjs_config->delete(); + } + return; + } + + $new_settings['library_config'] = [ + 'xaxis' => [ + 'autoskip' => TRUE, + 'horizontal_axis_title_align' => 'start', + ], + 'yaxis' => [ + 'vertical_axis_title_align' => 'start', + ], + ]; + $chartjs_config->delete(); +} diff --git a/web/modules/charts/modules/charts_chartjs/composer.json b/web/modules/charts/modules/charts_chartjs/composer.json new file mode 100755 index 00000000..63e216ef --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/composer.json @@ -0,0 +1,38 @@ +{ + "name": "drupal/charts_chartjs", + "description": "Charts module integration with Chart.js Charts.", + "type": "drupal-module", + "homepage": "http://drupal.org/project/charts", + "support": { + "issues": "https://www.drupal.org/project/issues/charts" + }, + "license": "GPL-2.0+", + "repositories": { + "asset-packagist": { + "type": "composer", + "url": "https://asset-packagist.org" + } + }, + "require": { + "oomphinc/composer-installers-extender": "^2", + "npm-asset/chart.js": "^4.4", + "npm-asset/chartjs-adapter-date-fns": "^3.0", + "npm-asset/chartjs-plugin-datalabels": "^2.0" + }, + "extra": { + "installer-types": [ + "npm-asset" + ], + "installer-paths": { + "web/libraries/chartjs": [ + "npm-asset/chart.js" + ], + "web/libraries/chartjs-adapter-date-fns": [ + "npm-asset/chartjs-adapter-date-fns" + ], + "web/libraries/chartjs-plugin-datalabels": [ + "npm-asset/chartjs-plugin-datalabels" + ] + } + } +} diff --git a/web/modules/charts/modules/charts_chartjs/config/schema/charts_chartjs.schema.yml b/web/modules/charts/modules/charts_chartjs/config/schema/charts_chartjs.schema.yml new file mode 100644 index 00000000..759ebd2c --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/config/schema/charts_chartjs.schema.yml @@ -0,0 +1,20 @@ +charts.settings.library_plugin.chartjs: + type: charts_settings_library_plugin + mapping: + xaxis: + label: 'X-Axis settings' + type: mapping + mapping: + autoskip: + label: 'Enable autoskip' + type: boolean + horizontal_axis_title_align: + label: 'Horizontal axis title alignment' + type: string + yaxis: + label: 'Y-Axis settings' + type: mapping + mapping: + vertical_axis_title_align: + label: 'Vertical axis title alignment' + type: string diff --git a/web/modules/charts/modules/charts_chartjs/js/charts_chartjs.js b/web/modules/charts/modules/charts_chartjs/js/charts_chartjs.js new file mode 100755 index 00000000..24b5fa7d --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/js/charts_chartjs.js @@ -0,0 +1,51 @@ +/** + * @file + * JavaScript integration between Chart.js and Drupal. + */ +(function (Drupal, once) { + + 'use strict'; + + function copyAttributes(source, target) { + return Array.from(source.attributes).forEach(attribute => { + target.setAttribute( + attribute.nodeName === 'id' ? 'data-id' : attribute.nodeName, + attribute.nodeValue, + ); + }); + } + + Drupal.behaviors.chartsChartjs = { + attach: function (context) { + const contents = new Drupal.Charts.Contents(); + once('load-charts-chartjs', '.charts-chartjs', context).forEach(function (element) { + const chartId = element.id; + // Switching div for canvas element. + const parent = element.parentNode; + const canvas = document.createElement('canvas'); + // Transferring the attributes of our source element to the canvas. + copyAttributes(element, canvas); + canvas.id = chartId; + parent.replaceChild(canvas, element); + + // Initializing the chart item. + const chart = contents.getData(chartId); + const options = chart['options']; + const enabled_plugins = []; + // If options.plugins.dataLabels.display is set to true, we need to add the plugin to the chart. + if (options.plugins && options.plugins.dataLabels && options.plugins.dataLabels.display) { + enabled_plugins.push(ChartDataLabels); + } + new Chart(chartId, { + type: chart['type'], + data: chart['data'], + plugins: enabled_plugins, + options: options, + }); + if (canvas.nextElementSibling && canvas.nextElementSibling.hasAttribute('data-charts-debug-container')) { + canvas.nextElementSibling.querySelector('code').innerText = JSON.stringify(chart, null, ' '); + } + }); + } + }; +}(Drupal, once)); diff --git a/web/modules/charts/modules/charts_chartjs/src/Plugin/chart/Library/Chartjs.php b/web/modules/charts/modules/charts_chartjs/src/Plugin/chart/Library/Chartjs.php new file mode 100755 index 00000000..6c6f9563 --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/src/Plugin/chart/Library/Chartjs.php @@ -0,0 +1,659 @@ + [ + 'autoskip' => TRUE, + 'horizontal_axis_title_align' => 'start', + ], + 'yaxis' => [ + 'vertical_axis_title_align' => 'start', + ], + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + $form['intro_text'] = [ + '#markup' => $this->t('

This is a placeholder for Chart.js-specific library options. If you would like to add more chartjs specific settings, please work from this issue.

', [ + '@issue_link' => Url::fromUri('https://www.drupal.org/project/charts/issues/3046984')->toString(), + ]), + ]; + + $xaxis_configuration = $this->configuration['xaxis'] ?? []; + $form['xaxis'] = [ + '#title' => $this->t('X-Axis Settings'), + '#type' => 'fieldset', + '#tree' => TRUE, + ]; + $form['xaxis']['autoskip'] = [ + '#title' => $this->t('Enable autoskip'), + '#type' => 'checkbox', + '#default_value' => !empty($xaxis_configuration['autoskip']), + ]; + $form['xaxis']['horizontal_axis_title_align'] = [ + '#title' => $this->t('Align horizontal axis title'), + '#type' => 'select', + '#options' => [ + 'start' => $this->t('Start'), + 'center' => $this->t('Center'), + 'end' => $this->t('End'), + ], + '#default_value' => $xaxis_configuration['horizontal_axis_title_align'] ?? 'center', + ]; + $form['yaxis'] = [ + '#title' => $this->t('Y-Axis Settings'), + '#type' => 'fieldset', + '#tree' => TRUE, + ]; + $form['yaxis']['vertical_axis_title_align'] = [ + '#title' => $this->t('Align vertical axis title'), + '#type' => 'select', + '#options' => [ + 'start' => $this->t('Start'), + 'center' => $this->t('Center'), + 'end' => $this->t('End'), + ], + '#default_value' => $this->configuration['yaxis']['vertical_axis_title_align'] ?? 'center', + ]; + + return $form; + } + + /** + * Build configurations. + * + * @param array $form + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + + if (!$form_state->getErrors()) { + $values = $form_state->getValue($form['#parents']); + $this->configuration['xaxis'] = $values['xaxis']; + $this->configuration['yaxis'] = $values['yaxis']; + } + } + + /** + * {@inheritdoc} + */ + public function preRender(array $element) { + $chart_definition = []; + + if (!isset($element['#id'])) { + $element['#id'] = Html::getUniqueId('chartjs-render'); + } + + if (!empty($element['#height']) || !empty($element['#width'])) { + $element['#content_prefix']['manual_sizing'] = [ + '#type' => 'inline_template', + '#template' => '
', + ]; + $element['#content_suffix']['manual_sizing'] = [ + '#type' => 'inline_template', + '#template' => '
', + ]; + } + + $chart_definition = $this->populateOptions($element, $chart_definition); + $chart_definition = $this->populateCategories($element, $chart_definition); + $chart_definition = $this->populateDatasets($element, $chart_definition); + $chart_definition = $this->populateAxes($element, $chart_definition); + + // Merge in chart raw options. + if (!empty($element['#raw_options'])) { + $chart_definition = NestedArray::mergeDeepArray([ + $chart_definition, + $element['#raw_options'], + ]); + } + + $element['#attached']['library'][] = 'charts_chartjs/chartjs'; + $element['#attributes']['class'][] = 'charts-chartjs'; + $element['#chart_definition'] = $chart_definition; + + return $element; + } + + /** + * Populate chart axes. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateAxes(array $element, array $chart_definition) { + $stacking = !empty($element['#stacking']) && $element['#stacking'] == 1; + $chart_type = $chart_definition['type']; + $children = Element::children($element); + /* + * Setting defaults based on what Views uses. However, API users may + * have different keys for their X and Y axes. + */ + $axes_info = []; + foreach ($children as $child) { + $type = $element[$child]['#type']; + if ($type === 'chart_xaxis') { + $x_axis_key = $child; + $xaxis_configuration = $this->configuration['xaxis'] ?? []; + if (!in_array($chart_type, $this->getPieStyleTypes())) { + if ($chart_type !== 'radar') { + $chart_definition['options']['scales']['x'] = [ + 'stacked' => $stacking, + 'ticks' => [ + 'autoSkip' => $xaxis_configuration['autoskip'] ?? 1, + 'maxRotation' => $element[$x_axis_key]['#labels_rotation'] ?? 0, + 'minRotation' => $element[$x_axis_key]['#labels_rotation'] ?? 0, + ], + ]; + if (!empty($element[$x_axis_key]['#title'])) { + $chart_definition['options']['scales']['x']['title']['display'] = TRUE; + $chart_definition['options']['scales']['x']['title']['text'] = $element[$x_axis_key]['#title']; + $chart_definition['options']['scales']['x']['title']['align'] = $xaxis_configuration['horizontal_axis_title_align'] ?? ''; + } + } + } + $axes_info['x'] = [ + 'element' => $element[$x_axis_key] ?? [], + 'config' => $this->configuration['xaxis'] ?? [], + ]; + } + elseif ($type === 'chart_yaxis') { + $target_axes = array_column($chart_definition['data']['datasets'],'yAxisID'); + $y_axis_key = in_array($child, $target_axes) ? $child : 'y'; + $axes_info[$y_axis_key] = [ + 'element' => $element[$child] ?? [], + 'config' => $this->configuration['yaxis'] ?? [], + ]; + } + } + + // Build axes options in chart_definition. + if (!in_array($chart_type, $this->getPieStyleTypes())) { + if (!empty($element['#stacking']) && $element['#stacking'] == 1) { + $stacking = TRUE; + } + else { + $stacking = FALSE; + } + if ($chart_type !== 'radar') { + $chart_definition['options']['scales']['x'] = [ + 'stacked' => $stacking, + 'ticks' => [ + 'autoSkip' => $axes_info['x']['config']['autoskip'] ?? 1, + 'maxRotation' => $axes_info['x']['element']['#labels_rotation'] ?? 0, + 'minRotation' => $axes_info['x']['element']['#labels_rotation'] ?? 0, + ], + ]; + + // Set configured values for each axis. + foreach ($axes_info as $axis_id => $axis_info) { + $axis_element = $axis_info['element']; + $axis_config = $axis_info['config']; + + if ($axis_element['#type'] == 'chart_yaxis') { + $chart_definition['options']['scales'][$axis_id] = [ + 'ticks' => [ + 'beginAtZero' => NULL, + 'maxRotation' => $axes_info[$axis_id]['element']['#labels_rotation'] ?? 0, + 'minRotation' => $axes_info[$axis_id]['element']['#labels_rotation'] ?? 0, + ], + 'maxTicksLimit' => 11, + 'precision' => NULL, + 'stepSize' => NULL, + 'suggestedMax' => NULL, + 'suggestedMin' => NULL, + 'stacked' => $stacking, + ]; + } + + // Set the axis type. + if (!empty($axis_element['#axis_type'])) { + $chart_definition['options']['scales'][$axis_id]['type'] = $axis_element['#axis_type']; + } + + // Set axis position. + if (!empty($axis_element['#opposite'])) { + $chart_definition['options']['scales'][$axis_id]['position'] = 'right'; + } + + // Set min and max values. + foreach (['min', 'max'] as $range_value) { + if (isset($axis_element["#$range_value"])) { + $chart_definition['options']['scales'][$axis_id][$range_value] = $axis_element["#$range_value"]; + } + } + + // Set title properties. + if (!empty($axis_element['#title'])) { + $chart_definition['options']['scales'][$axis_id]['title']['display'] = TRUE; + $chart_definition['options']['scales'][$axis_id]['title']['text'] = $axis_element['#title']; + + if (!empty($axis_config['vertical_axis_title_align'])) { + $chart_definition['options']['scales'][$axis_id]['title']['align'] = $axis_config['vertical_axis_title_align']; + } + } + + // Set title color. + if (!empty($axis_element['#title_color'])) { + $chart_definition['options']['scales'][$axis_id]['title']['color'] = $axis_element['#title_color']; + } + + // Set title font. + foreach (['weight', 'style', 'size'] as $font_attribute) { + $config_name = "#title_font_$font_attribute"; + if (!empty($axis_element[$config_name])) { + $chart_definition['options']['scales'][$axis_id]['title']['font'][$font_attribute] = $axis_element[$config_name]; + } + } + + // Set tick color. + foreach (['color', 'rotation'] as $tick_attribute) { + $config_name = "#labels_$tick_attribute"; + if (!empty($axis_element[$config_name])) { + $chart_definition['options']['scales'][$axis_id]['ticks'][$tick_attribute] = $axis_element[$config_name]; + } + } + + // Set tick font. + foreach (['weight', 'style', 'size'] as $font_attribute) { + $config_name = "#labels_font_$font_attribute"; + if (!empty($axis_element[$config_name])) { + $chart_definition['options']['scales'][$axis_id]['ticks']['font'][$font_attribute] = $axis_element[$config_name]; + } + } + // Set grid line colors. + if (!empty($axis_element['#grid_line_color'])) { + $chart_definition['options']['scales'][$axis_id]['grid']['color'] = $axis_element['#grid_line_color']; + } + if (!empty($axis_element['#base_line_color'])) { + $chart_definition['options']['scales'][$axis_id]['grid']['borderColor'] = $axis_element['#base_line_color']; + } + } + } + } + + return $chart_definition; + } + + /** + * Populate options. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateOptions(array $element, array $chart_definition) { + $chart_type = $this->populateChartType($element); + $chart_definition['type'] = $chart_type; + + // Horizontal bar charts are configured by changing the bar chart indexAxis. + // See https://chartjs.org/docs/latest/charts/bar.html#horizontal-bar-chart. + if ($element['#chart_type'] === 'bar') { + $chart_definition['options']['indexAxis'] = 'y'; + } + + $chart_definition['options']['plugins']['title'] = $this->buildTitle($element); + if (!empty($element['#subtitle'])) { + $chart_definition['options']['plugins']['subtitle'] = [ + 'display' => TRUE, + 'text' => $element['#subtitle'], + ]; + } + $chart_definition['options']['plugins']['tooltip']['enabled'] = $element['#tooltips']; + if (!empty($element['#data_labels'])) { + $chart_definition['options']['plugins']['dataLabels']['display'] = TRUE; + } + $chart_definition['options']['plugins']['legend'] = $this->buildLegend($element); + + return $chart_definition; + } + + /** + * Populate categories. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateCategories(array $element, array $chart_definition) { + $children = Element::children($element); + $categories = []; + foreach ($children as $child) { + $type = $element[$child]['#type']; + if ($type === 'chart_xaxis' && isset($element[$child]['#labels'])) { + $categories = is_array($element[$child]['#labels']) ? array_map('strip_tags', $element[$child]['#labels']) : []; + } + if (in_array($element['#chart_type'], $this->getPieStyleTypes()) + && $type !== 'chart_xaxis') { + if ($element[$child]['#type'] === 'chart_data') { + // Get the first item in each array inside $element[$child]['#data']. + $categories = array_map(function ($item) { + if (!empty($item['color'])) { + unset($item['color']); + } + return gettype($item) === 'array' ? array_values($item) : $item; + }, $element[$child]['#data']); + } + } + // Merge in axis raw options. + if (!empty($element[$child]['#raw_options'])) { + $categories = NestedArray::mergeDeepArray([ + $categories, + $element[$child]['#raw_options'], + ]); + } + } + $chart_definition['data']['labels'] = $categories; + return $chart_definition; + } + + /** + * Populate Dataset. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateDatasets(array $element, array $chart_definition) { + $chart_type = $chart_definition['type']; + $datasets = []; + foreach (Element::children($element) as $key) { + if ($element[$key]['#type'] === 'chart_data') { + $series_data = []; + $dataset = []; + // Populate the data. + foreach ($element[$key]['#data'] as $data_index => $data) { + if (isset($series_data[$data_index])) { + $series_data[$data_index][] = $data; + } + else { + if ($chart_type === 'scatter') { + $data = ['y' => $data[1], 'x' => $data[0]]; + } + if ($chart_type === 'bubble') { + /* + * The radius is not scaled in Chart.js, so it can look very bad. + * For suggestions about how to deal with this, see: + * https://github.com/chartjs/Chart.js/issues/3355 + */ + $data = ['y' => $data[1], 'x' => $data[0], 'r' => $data[2]]; + } + // Convert the array from Views when using pie-type charts + // and no label field. + if (in_array($chart_type, $this->getPieStyleTypes()) && !empty($data['color'])) { + $element['#colors'][$data_index] = $data['color']; + unset($data['color']); + $data = array_values($data); + } + /* + * This is here to account for differences between Views and + * the API. Will change if someone can find a better way. + */ + if (in_array($chart_type, $this->getPieStyleTypes()) && !empty($data[1])) { + $data = $data[1]; + } + $series_data[$data_index] = $data; + } + } + if (!empty($element[$key]['#target_axis'])) { + $dataset['yAxisID'] = $element[$key]['#target_axis']; + } + $dataset['label'] = $element[$key]['#title']; + $dataset['data'] = $series_data; + + // Set the background and border color. + if (!empty($element[$key]['#color'])) { + if (!in_array($chart_type, $this->getPieStyleTypes())) { + $dataset['borderColor'] = $element[$key]['#color']; + } + $dataset['backgroundColor'] = $element[$key]['#color']; + } + if (in_array($chart_type, $this->getPieStyleTypes()) && !empty($element['#colors'])) { + $dataset['backgroundColor'] = $element['#colors']; + } + + $series_type = isset($element[$key]['#chart_type']) ? $this->populateChartType($element[$key]) : $chart_type; + $dataset['type'] = $series_type; + if (!empty($element[$key]['#chart_type']) && $element[$key]['#chart_type'] === 'area') { + $dataset['fill'] = 'origin'; + $dataset['backgroundColor'] = $this->getTranslucentColor($element[$key]['#color']); + } + elseif ($element['#chart_type'] === 'area') { + $dataset['fill'] = 'origin'; + $dataset['backgroundColor'] = $this->getTranslucentColor($element[$key]['#color']); + } + else { + $dataset['fill'] = FALSE; + } + + // Merge in dataset raw options. + if (!empty($element[$key]['#raw_options'])) { + $dataset = NestedArray::mergeDeepArray([ + $dataset, + $element[$key]['#raw_options'], + ]); + } + + $datasets[] = $dataset; + } + + } + + $chart_definition['data']['datasets'] = $datasets; + + return $chart_definition; + } + + /** + * Outputs a type that can be used by Chart.js. + * + * @param array $element + * The given element. + * + * @return string + * The generated type. + */ + protected function populateChartType(array $element) { + switch ($element['#chart_type']) { + case 'bar': + + case 'column': + $type = 'bar'; + break; + + case 'area': + + case 'spline': + $type = 'line'; + break; + + case 'donut': + $type = 'doughnut'; + break; + + case 'gauge': + // Gauge is currently not supported by Chart.js. + $type = 'donut'; + break; + + default: + $type = $element['#chart_type']; + break; + } + if (isset($element['#polar']) && $element['#polar'] == 1) { + if ($element['#chart_type'] === 'area' || $element['#chart_type'] === 'polarArea') { + $type = 'polarArea'; + } + else { + $type = 'radar'; + } + } + + return $type; + } + + /** + * Builds legend based on element properties. + * + * @param array $element + * The element. + * + * @return array + * The legend array. + */ + protected function buildLegend(array $element) { + $legend = []; + // Configure the legend display. + $legend['display'] = (bool) $element['#legend']; + + // Configure legend position. + if (!empty($element['#legend_position'])) { + $legend['position'] = $element['#legend_position']; + if (!empty($element['#legend_font_weight'])) { + $legend['labels']['font']['weight'] = $element['#legend_font_weight']; + } + if (!empty($element['#legend_font_style'])) { + $legend['labels']['font']['style'] = $element['#legend_font_style']; + } + if (!empty($element['#legend_font_size'])) { + $legend['labels']['font']['size'] = $element['#legend_font_size']; + } + } + + return $legend; + } + + /** + * Builds title based on element properties. + * + * @param array $element + * The element. + * + * @return array + * The title array. + */ + protected function buildTitle(array $element) { + $title = []; + if (!empty($element['#title'])) { + $title = [ + 'display' => TRUE, + 'text' => $element['#title'], + ]; + if (!empty($element['#title_position'])) { + if (in_array($element['#title_position'], ['in', 'out'])) { + $title['position'] = 'top'; + } + else { + $title['position'] = $element['#title_position']; + } + } + if (!empty($element['#title_color'])) { + $title['color'] = $element['#title_color']; + } + if (!empty($element['#title_font_weight'])) { + $title['font']['weight'] = $element['#title_font_weight']; + } + if (!empty($element['#title_font_style'])) { + $title['font']['style'] = $element['#title_font_style']; + } + if (!empty($element['#title_font_size'])) { + $title['font']['size'] = $element['#title_font_size']; + } + } + + return $title; + } + + /** + * Get translucent color. + * + * @param string $color + * The color. + * + * @return string + * The color. + */ + protected function getTranslucentColor($color) { + if (!$color) { + return ''; + } + $rgb = Color::hexToRgb($color); + + return 'rgba(' . implode(",", $rgb) . ',' . 0.5 . ')'; + } + + /** + * Returns pie-style chart types. + * + * @return array + * An array of pie-style chart types. + */ + private static function getPieStyleTypes(): array { + return [ + 'pie', + 'doughnut', + 'polarArea', + ]; + } + +} diff --git a/web/modules/charts/modules/charts_chartjs/tests/src/Kernel/DimensionsTest.php b/web/modules/charts/modules/charts_chartjs/tests/src/Kernel/DimensionsTest.php new file mode 100644 index 00000000..4e8f96a4 --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/tests/src/Kernel/DimensionsTest.php @@ -0,0 +1,43 @@ +getChartStyle($element, '[data-chartjs-render-wrapper]'); + $width = $styles['width'] ?? NULL; + $height = $styles['height'] ?? NULL; + $this->assertEquals($expected_width, $width); + $this->assertEquals($expected_height, $height); + } + +} diff --git a/web/modules/charts/modules/charts_chartjs/tests/src/Kernel/RawOptionsTest.php b/web/modules/charts/modules/charts_chartjs/tests/src/Kernel/RawOptionsTest.php new file mode 100644 index 00000000..7d119c82 --- /dev/null +++ b/web/modules/charts/modules/charts_chartjs/tests/src/Kernel/RawOptionsTest.php @@ -0,0 +1,43 @@ + 'chart', + '#chart_type' => 'bar', + '#stacking' => 1, + '#raw_options' => [ + 'options' => [ + 'scales' => [ + 'x' => ['stacked' => FALSE], + ], + ], + ], + ]; + + $path = ['options', 'scales', 'x', 'stacked']; + $this->assertJsonPropertyHasValue($element, $path, FALSE); + } + +} diff --git a/web/modules/charts/modules/charts_google/README.md b/web/modules/charts/modules/charts_google/README.md new file mode 100755 index 00000000..3c9c2c28 --- /dev/null +++ b/web/modules/charts/modules/charts_google/README.md @@ -0,0 +1,42 @@ +#Installation using Composer (recommended) + +If you use Composer to manage dependencies, edit your site's "composer.json" +file as follows. + +1. Run `composer require --prefer-dist composer/installers` to ensure that +you have the "composer/installers" package installed. This package +facilitates the installation of packages into directories other than +"/vendor" (e.g. "/libraries") using Composer. + +2. Add the following to the "installer-paths" section of "composer.json": + + "libraries/{$name}": ["type:drupal-library"], + +3. Add the following to the "repositories" section of "composer.json": + + { + "type": "package", + "package": { + "name": "google/charts", + "version": "45", + "type": "drupal-library", + "extra": { + "installer-name": "google_charts" + }, + "dist": { + "url": "https://www.gstatic.com/charts/loader.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + } + +4. Run `composer require --prefer-dist google/charts:45` - you should find +that new directories have been created under "/libraries" + +**Please note**: if you observe an SSL error when trying to download this +library, you can address this by changing the "url" in the code above to +"http://www.gstatic.com/charts/loader.js" and by adding in: +"secure-http": false diff --git a/web/modules/charts/modules/charts_google/charts_google.charts_types.yml b/web/modules/charts/modules/charts_google/charts_google.charts_types.yml new file mode 100755 index 00000000..f7c4a222 --- /dev/null +++ b/web/modules/charts/modules/charts_google/charts_google.charts_types.yml @@ -0,0 +1,5 @@ +table: + label: 'Table' + axis: 'xy' + axis_inverted: false + stacking: false diff --git a/web/modules/charts/modules/charts_google/charts_google.info.yml b/web/modules/charts/modules/charts_google/charts_google.info.yml new file mode 100644 index 00000000..7a7e4c1b --- /dev/null +++ b/web/modules/charts/modules/charts_google/charts_google.info.yml @@ -0,0 +1,12 @@ +name: 'Google Charts' +type: module +description: 'Charts module integration with Google Charts.' +package: Charts +core_version_requirement: ^10.3 || ^11 +dependencies: + - 'charts:charts' + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/modules/charts_google/charts_google.install b/web/modules/charts/modules/charts_google/charts_google.install new file mode 100644 index 00000000..f2d56271 --- /dev/null +++ b/web/modules/charts/modules/charts_google/charts_google.install @@ -0,0 +1,71 @@ + t('Google Charts Library'), + 'value' => t('Not Installed'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('You are missing the Google Charts library in your Drupal installation directory. Please see the README file inside charts_google for instructions to install the library.'), + ]; + } + else { + $requirements['charts_google_js'] = [ + 'title' => t('Google Charts Library'), + 'severity' => REQUIREMENT_OK, + 'value' => t('Installed'), + ]; + } + break; + } + + return $requirements; +} + +/** + * Get the location of the Google Charts library. + * + * @return string + * The location of the library, or FALSE if the library isn't installed. + */ +function charts_google_find_library() { + // The following logic is taken from libraries_get_libraries() + $searchdir = []; + + // Similar to 'modules' and 'themes' directories inside an installation + // profile, installation profiles may want to place libraries into a + // 'libraries' directory. + $searchdir[] = 'profiles/' . \Drupal::installProfile() . '/libraries'; + + // Always search libraries. + $searchdir[] = 'libraries'; + + // Also search sites//*. + $container = \Drupal::getContainer(); + $site_path = $container->getParameter('site.path'); + $site_path = explode('/', $site_path); + $site_name = $site_path[1]; + $searchdir[] = $site_name . '/libraries'; + + foreach ($searchdir as $dir) { + if (file_exists($dir . '/google_charts/loader.js')) { + return $dir . '/google_charts'; + } + } + + return FALSE; +} diff --git a/web/modules/charts/modules/charts_google/charts_google.libraries.yml b/web/modules/charts/modules/charts_google/charts_google.libraries.yml new file mode 100755 index 00000000..c55951c4 --- /dev/null +++ b/web/modules/charts/modules/charts_google/charts_google.libraries.yml @@ -0,0 +1,22 @@ +charts_google: + version: 1.x + js: + js/charts_google.js: { weight: -1 } + dependencies: + - core/drupal + - core/drupalSettings + - charts/global + - core/once +google: + remote: 'https://www.gstatic.com/charts/loader.js' + version: VERSION + license: + name: 'Apache 2.0' + url: 'http://www.apache.org/licenses/LICENSE-2.0' + gpl-compatible: false + cdn: + /libraries/google_charts/: https://www.gstatic.com/charts/ + js: + /libraries/google_charts/loader.js: { } + dependencies: + - charts_google/charts_google diff --git a/web/modules/charts/modules/charts_google/charts_google.module b/web/modules/charts/modules/charts_google/charts_google.module new file mode 100644 index 00000000..ef3981f8 --- /dev/null +++ b/web/modules/charts/modules/charts_google/charts_google.module @@ -0,0 +1,17 @@ +getEditable('charts_google.settings'); + if ($google_config) { + $google_config->delete(); + } +} diff --git a/web/modules/charts/modules/charts_google/composer.json b/web/modules/charts/modules/charts_google/composer.json new file mode 100755 index 00000000..d3e67beb --- /dev/null +++ b/web/modules/charts/modules/charts_google/composer.json @@ -0,0 +1,33 @@ +{ + "name": "drupal/charts_google", + "description": "Charts module integration with Google Charts.", + "type": "drupal-module", + "homepage": "http://drupal.org/project/charts", + "support": { + "issues": "https://www.drupal.org/project/issues/charts" + }, + "license": "GPL-2.0+", + "require": { + "google/charts": "45" + }, + "repositories": { + "google_charts": { + "type": "package", + "package": { + "name": "google/charts", + "version": "45", + "type": "drupal-library", + "extra": { + "installer-name": "google_charts" + }, + "dist": { + "url": "https://www.gstatic.com/charts/loader.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + } + } +} diff --git a/web/modules/charts/modules/charts_google/config/schema/charts_google.schema.yml b/web/modules/charts/modules/charts_google/config/schema/charts_google.schema.yml new file mode 100644 index 00000000..057eb3dc --- /dev/null +++ b/web/modules/charts/modules/charts_google/config/schema/charts_google.schema.yml @@ -0,0 +1,6 @@ +charts.settings.library_plugin.google: + type: charts_settings_library_plugin + mapping: + use_material_design: + label: 'Use Material Design' + type: boolean diff --git a/web/modules/charts/modules/charts_google/js/charts_google.js b/web/modules/charts/modules/charts_google/js/charts_google.js new file mode 100755 index 00000000..01a4aab2 --- /dev/null +++ b/web/modules/charts/modules/charts_google/js/charts_google.js @@ -0,0 +1,187 @@ +/** + * @file + * JavaScript integration between Google and Drupal. + */ +(function (Drupal, drupalSettings, once) { + + 'use strict'; + + Drupal.googleCharts = Drupal.googleCharts || {charts: []}; + + /** + * Behavior to initialize Google Charts. + * + * @type {{attach: Drupal.behaviors.chartsGooglecharts.attach}} + */ + Drupal.behaviors.chartsGooglecharts = { + attach: function (context) { + // Define a fallback value for globalOptions; + const globalOptions = drupalSettings.charts === undefined ? { + useMaterialDesign: false, + chartType: 'bar' + } : drupalSettings.charts.google.global_options; + let useMaterialDesign = globalOptions.useMaterialDesign; + let chartType = globalOptions.chartType; + const materialDesignPackages = ['bar', 'line', 'spline', 'scatter', 'column',]; + const packages = ['corechart', 'gauge', 'table',]; + if (useMaterialDesign === 'true' && materialDesignPackages.indexOf(chartType) !== -1) { + if (chartType === 'spline') { + chartType = 'line'; + } + if (chartType === 'column') { + chartType = 'bar'; + } + packages.push(chartType); + } + // Load Google Charts API. + google.charts.load('current', {packages: packages}); + + // Re-draw charts if viewport size has been changed. + window.addEventListener('resize', function () { + Drupal.googleCharts.waitForFinalEvent(function () { + // Re-draw Google Charts. + Drupal.googleCharts.drawCharts(); + }, 200, 'reload-google-charts'); + }); + + // Draw Google Charts. + Drupal.googleCharts.drawCharts(); + } + }; + + /** + * Helper function to draw Google Charts. + */ + Drupal.googleCharts.drawCharts = function () { + const contents = new Drupal.Charts.Contents(); + once('load-google-charts-item', '.charts-google').forEach(function (element) { + if (element.dataset.hasOwnProperty('chart')) { + const chartId = element.id; + const dataAttributes = contents.getData(chartId); + google.charts.setOnLoadCallback(Drupal.googleCharts.drawChart(chartId, dataAttributes['visualization'], dataAttributes['data'], dataAttributes['options'])); + if (element.nextElementSibling && element.nextElementSibling.hasAttribute('data-charts-debug-container')) { + element.nextElementSibling.querySelector('code').innerText = JSON.stringify(dataAttributes, null, ' '); + } + } + }); + }; + + /** + * Helper function to draw a Google Chart. + * + * @param {string} chartId - Chart Id. + * @param {string} chartType - Chart Type. + * @param {string} dataTable - Data. + * @param {string} googleChartOptions - Options. + * + * @return {function} Draw Chart. + */ + Drupal.googleCharts.drawChart = function (chartId, chartType, dataTable, googleChartOptions) { + return function () { + const data = google.visualization.arrayToDataTable(dataTable); + const options = googleChartOptions; + const googleChartTypeFormatted = chartType; + + let visualizationNamespace = 'visualization'; + let visualizationClass = chartType; + // Replace the 'Spline' chart type with 'Line'. + if (visualizationClass === 'SplineChart') { + visualizationClass = 'LineChart'; + } + if (options.theme === 'material') { + // Material Design wants to use the 'charts' namespace. + visualizationNamespace = 'charts'; + // Strip the 'Chart' suffix from the chart type. + visualizationClass = visualizationClass.replace('Chart', ''); + // Replace the 'Column' chart type with 'Bar'. + if (visualizationClass === 'Column') { + visualizationClass = 'Bar'; + } + } + console.log(visualizationClass); + + let chart; + switch (googleChartTypeFormatted) { + case 'BarChart': + case 'ColumnChart': + case 'LineChart': + case 'SplineChart': + case 'ScatterChart': + chart = new google[visualizationNamespace][visualizationClass](document.getElementById(chartId)); + break; + + case 'DonutChart': + case 'PieChart': + chart = new google.visualization.PieChart(document.getElementById(chartId)); + break; + + case 'BubbleChart': + chart = new google.visualization.BubbleChart(document.getElementById(chartId)); + break; + + case 'AreaChart': + chart = new google.visualization.AreaChart(document.getElementById(chartId)); + break; + + case 'Gauge': + chart = new google.visualization.Gauge(document.getElementById(chartId)); + break; + + case 'ComboChart': + chart = new google.visualization.ComboChart(document.getElementById(chartId)); + break; + + case 'GeoChart': + chart = new google.visualization.GeoChart(document.getElementById(chartId)); + break; + + case 'TableChart': + chart = new google.visualization.Table(document.getElementById(chartId)); + } + + let color_regex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; + if (options['colors'] && options['colors'].length > 10) { + for (const i in options['colors']) { + if (!color_regex.test(options['colors'][i])) { + options['colors'][i] = '#FFFFFF'; + } + } + } + + // Rewrite the colorAxis item to include the colors: key + if (typeof options['colorAxis'] != 'undefined') { + const colors = options['colorAxis']; + const num_colors = colors.length; + options['colorAxis'] = colors.splice(num_colors); + options['colorAxis'] = {colors: colors}; + } + chart.draw(data, options); + }; + }; + + /** + * Helper function to run a callback function once when triggering an event + * multiple times. + * + * Example usage: + * @code + * window.addEventListener('resize', function () { + * Drupal.googleCharts.waitForFinalEvent(function(){ + * alert('Resize...'); + * }, 500, "some unique string"); + * }); + * @endcode + */ + Drupal.googleCharts.waitForFinalEvent = (function () { + let timers = {}; + return function (callback, ms, uniqueId) { + if (!uniqueId) { + uniqueId = "Don't call this twice without a uniqueId"; + } + if (timers[uniqueId]) { + clearTimeout(timers[uniqueId]); + } + timers[uniqueId] = setTimeout(callback, ms); + }; + })(); +}(Drupal, drupalSettings, once)); diff --git a/web/modules/charts/modules/charts_google/src/Plugin/chart/Library/Google.php b/web/modules/charts/modules/charts_google/src/Plugin/chart/Library/Google.php new file mode 100755 index 00000000..596d634a --- /dev/null +++ b/web/modules/charts/modules/charts_google/src/Plugin/chart/Library/Google.php @@ -0,0 +1,600 @@ +moduleHandler = $module_handler; + $this->elementInfo = $element_info; + $this->chartTypeManager = $chart_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('module_handler'), + $container->get('element_info'), + $container->get('plugin.manager.charts_type') + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + + return [ + 'use_material_design' => FALSE, + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + $form['placeholder'] = [ + '#title' => $this->t('Placeholder'), + '#type' => 'fieldset', + '#description' => $this->t( + 'This is a placeholder for Google-specific library options. If you would like to help build this out, please work from this issue.', [ + '@issue_link' => Url::fromUri('https://www.drupal.org/project/charts/issues/3046980') + ->toString(), + ]), + ]; + $form['use_material_design'] = [ + '#title' => $this->t('Use Material Design'), + '#type' => 'checkbox', + '#default_value' => $this->configuration['use_material_design'] ?? FALSE, + '#description' => $this->t('Use Material Design for charts.'), + ]; + + return $form; + } + + /** + * Submit configurations. + * + * @param array $form + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + + if (!$form_state->getErrors()) { + $values = $form_state->getValue($form['#parents']); + $this->configuration['use_material_design'] = $values['use_material_design']; + } + } + + /** + * {@inheritdoc} + */ + public function preRender(array $element) { + $chart_definition = []; + // Convert the chart renderable to a proper definition. + $chart_definition['visualization'] = $this->chartsGoogleVisualizationType($element['#chart_type']); + $chart_definition = $this->chartsGooglePopulateChartOptions($element, $chart_definition); + $chart_definition = $this->chartsGooglePopulateChartAxes($element, $chart_definition); + $chart_definition = $this->chartsGooglePopulateChartData($element, $chart_definition); + + if (!isset($element['#id'])) { + $element['#id'] = Html::getUniqueId('google-chart-render'); + } + + if (!empty($element['#height']) || !empty($element['#width'])) { + $element['#attributes']['style'] = 'height:' . $element['#height'] . $element['#height_units'] . ';width:' . $element['#width'] . $element['#width_units'] . ';'; + } + + // Trim out empty options. + ChartElement::trimArray($chart_definition['options']); + + $element['#attached']['library'][] = 'charts_google/google'; + $element['#attributes']['class'][] = 'charts-google'; + $element['#chart_definition'] = $chart_definition; + + // Setting global options. + $element['#attached']['drupalSettings']['charts']['google']['global_options'] = [ + 'useMaterialDesign' => !empty($this->configuration['use_material_design']) ? 'true' : 'false', + 'chartType' => $element['#chart_type'], + ]; + + return $element; + } + + /** + * Utility to convert a Drupal renderable type to a Google visualization type. + */ + public function chartsGoogleVisualizationType($renderable_type) { + $types = [ + 'area' => 'AreaChart', + 'bar' => 'BarChart', + 'column' => 'ColumnChart', + 'line' => 'LineChart', + 'spline' => 'SplineChart', + 'pie' => 'PieChart', + 'donut' => 'DonutChart', + 'gauge' => 'Gauge', + 'scatter' => 'ScatterChart', + 'bubble' => 'BubbleChart', + 'geo' => 'GeoChart', + 'table' => 'TableChart', + ]; + $this->moduleHandler->alter('charts_google_visualization_types', $types); + return $types[$renderable_type] ?? FALSE; + } + + /** + * Utility to populate main chart options. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * The returned chart definition. + */ + public function chartsGooglePopulateChartOptions(array $element, array $chart_definition) { + if (!empty($this->configuration['use_material_design'])) { + $chart_definition['options']['theme'] = 'material'; + $chart_definition['options']['chart']['title'] = $element['#title'] ?? NULL; + $chart_definition['options']['chart']['subtitle'] = $element['#subtitle'] ?? NULL; + if ($element['#chart_type'] === 'bar') { + $chart_definition['options']['bars'] = 'horizontal'; + } + } + else { + $title = $element['#title'] ?? NULL; + if ($title && !empty($element['#subtitle'])) { + $title .= ': ' . $element['#subtitle']; + } + $chart_definition['options']['title'] = $title; + } + $chart_definition['options']['titleTextStyle']['color'] = $element['#title_color']; + $chart_definition['options']['titleTextStyle']['bold'] = $element['#title_font_weight'] === 'bold'; + $chart_definition['options']['titleTextStyle']['italic'] = $element['#title_font_style'] === 'italic'; + $chart_definition['options']['titleTextStyle']['fontSize'] = $element['#title_font_size']; + $chart_definition['options']['titlePosition'] = $element['#title_position']; + $chart_definition['options']['colors'] = $element['#colors']; + $chart_definition['options']['fontName'] = $element['#font']; + $chart_definition['options']['fontSize'] = $element['#font_size']; + $chart_definition['options']['backgroundColor']['fill'] = $element['#background']; + $chart_definition['options']['isStacked'] = (bool) $element['#stacking']; + $chart_definition['options']['tooltip']['trigger'] = $element['#tooltips'] ? 'focus' : 'none'; + $chart_definition['options']['tooltip']['isHtml'] = (bool) $element['#tooltips_use_html']; + $chart_definition['options']['pieSliceText'] = $element['#data_labels'] ? NULL : 'none'; + $chart_definition['options']['legend']['position'] = $element['#legend_position'] ?? 'none'; + $chart_definition['options']['legend']['alignment'] = 'center'; + $chart_definition['options']['interpolateNulls'] = TRUE; + if ($element['#chart_type'] === 'gauge') { + $chart_definition['options']['redFrom'] = $element['#gauge']['red_from']; + $chart_definition['options']['redTo'] = $element['#gauge']['red_to']; + $chart_definition['options']['yellowFrom'] = $element['#gauge']['yellow_from']; + $chart_definition['options']['yellowTo'] = $element['#gauge']['yellow_to']; + $chart_definition['options']['GreenFrom'] = $element['#gauge']['green_from']; + $chart_definition['options']['greenTo'] = $element['#gauge']['green_to']; + $chart_definition['options']['min'] = (int) $element['#gauge']['min']; + $chart_definition['options']['max'] = (int) $element['#gauge']['max']; + } + if ($element['#chart_type'] === 'donut') { + $chart_definition['options']['pieHole'] = 0.4; + } + if ($element['#chart_type'] === 'bubble') { + $chart_definition['options']['bubble']['textStyle']['fontSize'] = 11; + } + // @todo Legend title (and thus these properties) not supported by Google. + $chart_definition['options']['legend']['title'] = $element['#legend_title']; + $chart_definition['options']['legend']['titleTextStyle']['bold'] = $element['#legend_title_font_weight'] === 'bold'; + $chart_definition['options']['legend']['titleTextStyle']['italic'] = $element['#legend_title_font_style'] === 'italic'; + $chart_definition['options']['legend']['titleTextStyle']['fontSize'] = $element['#legend_title_font_size']; + + $chart_definition['options']['legend']['textStyle']['bold'] = $element['#legend_font_weight'] === 'bold'; + $chart_definition['options']['legend']['textStyle']['italic'] = $element['#legend_font_style'] === 'italic'; + $chart_definition['options']['legend']['textStyle']['fontSize'] = $element['#legend_font_size']; + // If your labels are truncated, you may need to add (try adjusting the %). + // $chart_definition['options']['chartArea']['height'] = '50%'. + $chart_definition['options']['animation']['duration'] = 10000; + $chart_definition['options']['animation']['easing'] = 'out'; + + // Merge in chart raw options. + if (!empty($element['#raw_options'])) { + $chart_definition = NestedArray::mergeDeepArray([ + $chart_definition, + $element['#raw_options'], + ]); + } + + return $chart_definition; + } + + /** + * Utility to populate chart axes. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + public function chartsGooglePopulateChartAxes(array $element, array $chart_definition) { + foreach (Element::children($element) as $key) { + if ($element[$key]['#type'] === 'chart_xaxis' || $element[$key]['#type'] === 'chart_yaxis') { + // Make sure defaults are loaded. + if (empty($element[$key]['#defaults_loaded'])) { + $element[$key] += $this->elementInfo->getInfo($element[$key]['#type']); + } + + // Populate the chart data. + $axis = []; + $axis['title'] = $element[$key]['#title'] ?? ''; + $axis['titleTextStyle']['color'] = $element[$key]['#title_color']; + $axis['titleTextStyle']['bold'] = $element[$key]['#title_font_weight'] === 'bold'; + $axis['titleTextStyle']['italic'] = $element[$key]['#title_font_style'] === 'italic'; + $axis['titleTextStyle']['fontSize'] = $element[$key]['#title_font_size']; + // In Google, the row column of data is used as labels. + if ($element[$key]['#labels'] && $element[$key]['#type'] === 'chart_xaxis') { + foreach ($element[$key]['#labels'] as $label_key => $label) { + $chart_definition['data'][$label_key + 1][0] = $label; + } + } + $axis['textStyle']['color'] = $element[$key]['#labels_color']; + $axis['textStyle']['bold'] = $element[$key]['#labels_font_weight'] === 'bold'; + $axis['textStyle']['italic'] = $element[$key]['#labels_font_style'] === 'italic'; + $axis['textStyle']['fontSize'] = $element[$key]['#labels_font_size']; + $axis['slantedText'] = !empty($element[$key]['#labels_rotation']) ? TRUE : NULL; + $axis['slantedTextAngle'] = $element[$key]['#labels_rotation']; + $axis['gridlines']['color'] = $element[$key]['#grid_line_color']; + $axis['baselineColor'] = $element[$key]['#base_line_color']; + $axis['minorGridlines']['color'] = $element[$key]['#minor_grid_line_color']; + $axis['viewWindowMode'] = isset($element[$key]['#max']) ? 'explicit' : NULL; + $axis['viewWindow']['max'] = !empty($element[$key]['#max']) ? (int) $element[$key]['#max'] : NULL; + $axis['viewWindow']['min'] = !empty($element[$key]['#min']) ? (int) $element[$key]['#min'] : NULL; + + // Merge in axis raw options. + if (!empty($element[$key]['#raw_options'])) { + $axis = NestedArray::mergeDeepArray([ + $axis, + $element[$key]['#raw_options'], + ]); + } + + // Multi-axis support only applies to the major axis in Google charts. + $chart_type = $this->chartTypeManager->getDefinition($element['#chart_type']); + $axis_index = $element[$key]['#opposite'] ? 1 : 0; + if ($element[$key]['#type'] === 'chart_xaxis') { + $axis_keys = !$chart_type['axis_inverted'] ? ['hAxis'] : [ + 'vAxes', + $axis_index, + ]; + } + else { + $axis_keys = !$chart_type['axis_inverted'] ? ['vAxes', $axis_index] : ['hAxis']; + } + $axis_drilldown = &$chart_definition['options']; + foreach ($axis_keys as $axis_key) { + $axis_drilldown = &$axis_drilldown[$axis_key]; + } + $axis_drilldown = $axis; + } + } + + return $chart_definition; + } + + /** + * Utility to populate chart data. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + public function chartsGooglePopulateChartData(array &$element, array $chart_definition) { + $chart_definition['options']['series'] = []; + $chart_type = $this->chartTypeManager->getDefinition($element['#chart_type']); + $series_number = 0; + foreach (Element::children($element) as $key) { + if ($element[$key]['#type'] === 'chart_data') { + $series = []; + + // Make sure defaults are loaded. + if (empty($element[$key]['#defaults_loaded'])) { + $element[$key] += $this->elementInfo->getInfo($element[$key]['#type']); + } + + // Convert target named axis keys to integers. + $axis_index = 0; + if (isset($element[$key]['#target_axis'])) { + $axis_name = $element[$key]['#target_axis']; + foreach (Element::children($element) as $axis_key) { + $multi_axis_type = $chart_type['axis_inverted'] ? 'chart_xaxis' : 'chart_yaxis'; + if ($element[$axis_key]['#type'] === $multi_axis_type) { + if ($axis_key === $axis_name) { + break; + } + $axis_index++; + } + } + $series['targetAxisIndex'] = $axis_index; + } + + // Allow data to provide the labels. This will override the axis + // settings. + if ($element[$key]['#labels']) { + foreach ($element[$key]['#labels'] as $label_index => $label) { + $chart_definition['data'][$label_index + 1][0] = $label; + } + } + if ($element[$key]['#title']) { + $chart_definition['data'][0][$series_number + 1] = $element[$key]['#title']; + } + foreach ($element[$key]['#data'] as $index => $data_value) { + // Nested array values typically used for scatter charts. This weird + // approach leaves columns empty in order to make arbitrary pairings. + // See https://developers.google.com/chart/interactive/docs/gallery/scatterchart#Data_Format + if (is_array($data_value)) { + if ($chart_type['id'] === 'scatter') { + $chart_definition['data'][] = [ + 0 => $data_value[0], + $series_number + 1 => $data_value[1], + ]; + } + elseif ($chart_type['id'] === 'bubble') { + $chart_definition['data'][] = [ + 0 => $element[$key]['#title'], + 1 => $data_value[0], + $series_number + 2 => $data_value[1], + $series_number + 3 => $data_value[2], + ]; + } + else { + if (!empty($data_value['color']) && (in_array($chart_type['id'], [ + 'pie', + 'donut', + ]))) { + $chart_definition['options']['slices'][$index]['color'] = $data_value['color']; + unset($data_value['color']); + $data_value = array_values($data_value); + } + $chart_definition['data'][$index + 1] = $data_value; + } + } + // Most charts provide a single-dimension array of values. + else { + if ($chart_type['id'] === 'gauge') { + $data_value = [$element[$key]['#title'], $data_value]; + $chart_definition['data'][$index + 1] = $data_value; + } + else { + $chart_definition['data'][$index + 1][$series_number + 1] = $data_value; + } + } + } + + $series['color'] = $element[$key]['#color']; + // Scatter charts are not visible if the data_markers are disabled. + if ($chart_type['id'] === 'scatter') { + $element['#data_markers'] = TRUE; + } + $series['pointSize'] = !empty($element['#data_markers']) ? 3 : 0; + $series['visibleInLegend'] = $element[$key]['#show_in_legend']; + + // Labels only supported on pies. + $series['pieSliceText'] = $element[$key]['#show_labels'] ? 'label' : 'none'; + + // These properties are not real Google Charts properties. They are + // utilized by the formatter in charts_google.js. + $decimal_count = $element[$key]['#decimal_count'] ? '.' . str_repeat('0', $element[$key]['#decimal_count']) : ''; + $prefix = $this->chartsGoogleEscapeIcuCharacters($element[$key]['#prefix'] ?? ''); + $suffix = $this->chartsGoogleEscapeIcuCharacters($element[$key]['#suffix'] ?? ''); + $format = $prefix . '#' . $decimal_count . $suffix; + $series['_format']['format'] = $format; + + // @todo Convert this from PHP's date format to ICU format. + // See https://developers.google.com/chart/interactive/docs/reference#dateformatter. + // $series['_format']['dateFormat'] = $element[$key]['#date_format']; + // Conveniently only the axis that supports multiple axes is the one; + // that can receive formatting, so we know that the key will; + // always be plural. + $axis_type = $chart_type['axis_inverted'] ? 'hAxes' : 'vAxes'; + $chart_definition['options'][$axis_type][$axis_index]['format'] = $format; + + // Convert to a ComboChart if mixing types. + // See https://developers.google.com/chart/interactive/docs/gallery/combochart?hl=en. + if ($element[$key]['#chart_type'] && !in_array($element[$key]['#chart_type'], ['bubble', 'scatter'])) { + // Oddly Google calls a "column" chart a "bars" series. + // Using actual bar. + // charts is not supported in combo charts with Google. + $main_chart_type = $element['#chart_type'] === 'column' ? 'bars' : $element['#chart_type']; + $chart_definition['visualization'] = 'ComboChart'; + $chart_definition['options']['seriesType'] = $main_chart_type; + + $data_chart_type = $element[$key]['#chart_type'] === 'column' ? 'bars' : $element[$key]['#chart_type']; + $series['type'] = $data_chart_type; + } + + // Merge in point raw options. + if (!empty($data_item['#raw_options'])) { + $series = NestedArray::mergeDeepArray([ + $series, + $data_item['#raw_options'], + ]); + } + + // Add the series to the main chart definition. + ChartElement::trimArray($series); + $chart_definition['options']['series'][$series_number] = $series; + + // Merge in any point-specific data points. + foreach (Element::children($element[$key]) as $sub_key) { + if ($element[$key][$sub_key]['#type'] === 'chart_data_item') { + + // Make sure defaults are loaded. + if (empty($element[$key][$sub_key]['#defaults_loaded'])) { + $element[$key][$sub_key] += $this->elementInfo->getInfo($element[$key][$sub_key]['#type']); + } + + $data_item = $element[$key][$sub_key]; + if ($data_item['#data']) { + $chart_definition['data'][$sub_key + 1][$series_number + 1] = $data_item['#data']; + } + // These data properties are manually applied to cells in JS. + // Color role not yet supported. See https://code.google.com/p/google-visualization-api-issues/issues/detail?id=1267 + $chart_definition['_data'][$sub_key + 1][$series_number + 1]['color'] = $data_item['#color']; + $chart_definition['_data'][$sub_key + 1][$series_number + 1]['tooltip'] = $data_item['#title']; + + // Merge in data point raw options. + if (!empty($data_item['#raw_options'])) { + $chart_definition['_data'][$sub_key + 1][$series_number + 1] = NestedArray::mergeDeepArray([ + $chart_definition['_data'][$sub_key + 1][$series_number + 1], + $data_item['#raw_options'], + ]); + } + + ChartElement::trimArray($chart_definition['_data'][$sub_key + 1][$series_number + 1]); + } + } + + $series_number++; + + } + } + + // Once complete, normalize the chart data to ensure a full 2D structure. + $data = $chart_definition['data']; + + if (in_array($element['#chart_type'], ['pie', 'donut', 'scatter'])) { + // Populate the 0th row with the same number of values as the 1st row. + $data[0] = array_fill(0, count($data[1]), $element[$key]['#title'] ?? ''); + } + elseif ($element['#chart_type'] === 'bubble') { + $data[0] = [ + $element[$key]['#title'] ?? '', + $chart_definition['options']['hAxis']['title'] ?? '', + $chart_definition['options']['vAxes'][0]['title'] ?? '', + $chart_definition['options']['zAxis']['title'] ?? '', + ]; + } + else { + array_unshift($data[0], ''); + } + // Ensure consistent column count. + $column_count = count($data[0]); + $new_data = []; + foreach ($data as $row => $values) { + for ($n = 0; $n < $column_count; $n++) { + if (!empty(array_values($values))) { + $temp = array_values($values); + $new_data[$row][$n] = $temp[$n] ?? NULL; + } + } + ksort($new_data[$row]); + } + ksort($new_data); + + $chart_definition['data'] = $new_data; + + return $chart_definition; + } + + /** + * Utility to escape special characters in ICU number formats. + * + * Google will use the ICU format to auto-adjust numbers based on special + * characters that are used in the format. This function escapes these special + * characters so they just show up as the character specified. + * + * The format string is a subset of the ICU pattern set. For instance, + * {pattern:'#,###%'} will result in output values "1,000%", "750%", and "50%" + * for values 10, 7.5, and 0.5. + */ + public function chartsGoogleEscapeIcuCharacters($string) { + return preg_replace('/([0-9@#\.\-,E\+;%\'\*])/', "'$1'", $string); + } + +} diff --git a/web/modules/charts/modules/charts_highcharts/README.md b/web/modules/charts/modules/charts_highcharts/README.md new file mode 100755 index 00000000..328e0ed2 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/README.md @@ -0,0 +1,130 @@ +#Installation using Composer (recommended) + +If you use Composer to manage dependencies, edit your site's "composer.json" +file as follows. + +1. Run `composer require --prefer-dist composer/installers` to ensure that +you have the "composer/installers" package installed. This package +facilitates the installation of packages into directories other than +"/vendor" (e.g. "/libraries") using Composer. + +2. Add the following to the "installer-paths" section of "composer.json": + + "libraries/{$name}": ["type:drupal-library"], + +3. Add the following to the "repositories" section of "composer.json": + + { + "type": "package", + "package": { + "name": "highcharts/highcharts", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/highcharts.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + { + "type": "package", + "package": { + "name": "highcharts/more", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_more" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/highcharts-more.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + { + "type": "package", + "package": { + "name": "highcharts/exporting", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_exporting" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/modules/exporting.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + { + "type": "package", + "package": { + "name": "highcharts/export-data", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_export-data" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/modules/export-data.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + { + "type": "package", + "package": { + "name": "highcharts/accessibility", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_accessibility" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/modules/accessibility.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + { + "type": "package", + "package": { + "name": "highcharts/3d", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_3d" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/highcharts-3d.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + } + +4. Run `composer require --prefer-dist highcharts/highcharts:11.1.0 +highcharts/more:11.1.0 highcharts/exporting:11.1.0 +highcharts/export-data:11.1.0 highcharts/accessibility:11.1.0 +highcharts/3d:11.1.0` - you should find that new directories have been +created under "/libraries" diff --git a/web/modules/charts/modules/charts_highcharts/charts_highcharts.info.yml b/web/modules/charts/modules/charts_highcharts/charts_highcharts.info.yml new file mode 100644 index 00000000..10cdac4c --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/charts_highcharts.info.yml @@ -0,0 +1,12 @@ +name: Highcharts +type: module +description: 'Charts module integration with Highcharts library.' +package: Charts +core_version_requirement: ^10.3 || ^11 +dependencies: + - charts:charts + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/modules/charts_highcharts/charts_highcharts.install b/web/modules/charts/modules/charts_highcharts/charts_highcharts.install new file mode 100755 index 00000000..09706d7b --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/charts_highcharts.install @@ -0,0 +1,95 @@ +get('advanced.requirements.cdn') ?? FALSE; + + if (!$library_path) { + if ($cdn) { + $requirements['charts_highcharts_js'] = [ + 'title' => t('Highcharts Library'), + 'value' => t('Available through a CDN'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('You are using the Highcharts library via a content delivery network. It is generally considered better practice to install the library files locally. Please see the README file inside charts_highcharts for instructions to install the library.'), + ]; + } + else { + $requirements['charts_highcharts_js'] = [ + 'title' => t('Highcharts Library'), + 'value' => t('Not Installed'), + 'severity' => REQUIREMENT_ERROR, + 'description' => t('You are missing the Highcharts library in your Drupal installation directory and you have opted not to use a CDN. Please either enable use of the CDN in the Chart Settings under the Advanced tab or see the README file inside charts_highcharts for instructions to install the library.'), + ]; + } + } + else { + $requirements['charts_highcharts_js'] = [ + 'title' => t('Highcharts Library'), + 'value' => t('Installed'), + 'severity' => REQUIREMENT_OK, + 'description' => t('You have installed the Highcharts.js library in your Drupal installation directory.'), + ]; + } + break; + } + + if (function_exists('libraries_detect') && $highcharts_info = libraries_detect('highcharts')) { + if (is_dir($highcharts_info['library path'] . '/js/exporting-server')) { + $requirements['charts_highcharts_js'] = [ + 'title' => t('Highcharts vulnerability'), + 'severity' => REQUIREMENT_ERROR, + 'value' => t('Dangerous sample code present'), + 'description' => t('Your installation of the Highcharts library at "@path" contains a directory named "exporting-server". This directory contains dangerous sample files that may compromise the security of your site. You must delete this directory before you may use the Charts Highcharts module.', ['@path' => $highcharts_info['library path']]), + ]; + } + } + + return $requirements; +} + +/** + * Get the location of the Highcharts library. + * + * @return string + * The location of the library, or FALSE if the library isn't installed. + */ +function charts_highcharts_find_library() { + // The following logic is taken from libraries_get_libraries() + $searchdir = []; + + // Similar to 'modules' and 'themes' directories inside an installation + // profile, installation profiles may want to place libraries into a + // 'libraries' directory. + $searchdir[] = 'profiles/' . \Drupal::installProfile() . '/libraries'; + + // Always search libraries. + $searchdir[] = 'libraries'; + + // Also search sites//*. + $container = \Drupal::getContainer(); + $site_path = $container->getParameter('site.path'); + $site_path = explode('/', $site_path); + $site_name = $site_path[1]; + $searchdir[] = $site_name . '/libraries'; + + foreach ($searchdir as $dir) { + if (file_exists($dir . '/highcharts/highcharts.js')) { + return $dir . '/highcharts'; + } + } + + return FALSE; +} diff --git a/web/modules/charts/modules/charts_highcharts/charts_highcharts.libraries.yml b/web/modules/charts/modules/charts_highcharts/charts_highcharts.libraries.yml new file mode 100755 index 00000000..509bb3ee --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/charts_highcharts.libraries.yml @@ -0,0 +1,73 @@ +charts_highcharts: + version: VERSION + js: + js/charts_highcharts.js: { weight: -1 } + dependencies: + - core/once + - core/drupalSettings + - charts/global + +highcharts: + remote: https://code.highcharts.com/highcharts.js + version: VERSION + license: + name: Non-commercial + url: https://creativecommons.org/licenses/by-nc/3.0/ + gpl-compatible: false + cdn: + /libraries/highcharts/highcharts.js: https://code.highcharts.com/11.1.0/highcharts.js + /libraries/highcharts_more/highcharts-more.js: https://code.highcharts.com/11.1.0/highcharts-more.js + /libraries/highcharts_accessibility/accessibility.js: https://code.highcharts.com/11.1.0/modules/accessibility.js + /libraries/highcharts_3d/highcharts-3d.js: https://code.highcharts.com/11.1.0/highcharts-3d.js + js: + /libraries/highcharts/highcharts.js: { minified: true } + /libraries/highcharts_more/highcharts-more.js: { minified: true } + /libraries/highcharts_accessibility/accessibility.js: { minified: true } + /libraries/highcharts_3d/highcharts-3d.js: { minified: true } + dependencies: + - charts_highcharts/charts_highcharts + +highcharts_exporting: + remote: https://code.highcharts.com/highcharts.js + version: VERSION + license: + name: Non-commercial + url: https://creativecommons.org/licenses/by-nc/3.0/ + gpl-compatible: false + cdn: + /libraries/highcharts_exporting/exporting.js: https://code.highcharts.com/11.1.0/modules/exporting.js + /libraries/highcharts_export-data/export-data.js: https://code.highcharts.com/11.1.0/modules/export-data.js + js: + /libraries/highcharts_exporting/exporting.js: { minified: true } + /libraries/highcharts_export-data/export-data.js: { minified: true } + dependencies: + - charts_highcharts/charts_highcharts + - charts_highcharts/highcharts + +texture: + remote: https://code.highcharts.com/highcharts.js + version: VERSION + license: + name: Non-commercial + url: https://creativecommons.org/licenses/by-nc/3.0/ + gpl-compatible: false + cdn: + /libraries/highcharts_pattern-fill/pattern-fill.js: https://code.highcharts.com/11.1.0/modules/pattern-fill.js + /libraries/highcharts_high-contrast-light/high-contrast-light.js: https://code.highcharts.com/11.1.0/themes/high-contrast-light.js + js: + /libraries/highcharts_pattern-fill/pattern-fill.js: { minified: true } + /libraries/highcharts_high-contrast-light/high-contrast-light.js: { minified: true } + js/textures.js: { weight: -5 } + dependencies: + - charts/global + - core/once + - charts_highcharts/charts_highcharts + +color_changer: + version: VERSION + js: + js/color_changer.js: { } + dependencies: + - charts/global + - core/once + - charts_highcharts/charts_highcharts diff --git a/web/modules/charts/modules/charts_highcharts/charts_highcharts.module b/web/modules/charts/modules/charts_highcharts/charts_highcharts.module new file mode 100644 index 00000000..01c7aca6 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/charts_highcharts.module @@ -0,0 +1,86 @@ +getEditable('charts_highcharts.settings') : NULL; + if (!$is_config || !$highcharts_config || empty($new_settings['library']) || $new_settings['library'] !== 'highcharts') { + if ($highcharts_config) { + $highcharts_config->delete(); + } + return; + } + + $mapping_keys = [ + 'legend_layout' => 'legend_layout', + 'legend_background_color' => 'legend_background_color', + 'legend_border_width' => 'legend_border_width', + 'legend_shadow' => 'legend_shadow', + 'item_style_color' => 'legend_item_style_color', + 'text_overflow' => 'legend_item_style_overflow', + ]; + $library_config = [ + 'legend' => [ + 'layout' => NULL, + 'background_color' => '', + 'border_width' => 0, + 'shadow' => FALSE, + 'item_style' => [ + 'color' => '', + 'overflow' => '', + ], + ], + 'exporting_library' => TRUE, + 'texture_library' => TRUE, + 'global_options' => Highcharts::defaultGlobalOptions(), + ]; + + foreach ($highcharts_config->getRawData() as $config_key => $config_value) { + $setting_key_map = $mapping_keys[$config_key] ?? ''; + if (!$setting_key_map || substr($setting_key_map, 0, 6) !== 'legend') { + continue; + } + + // Stripping the 'legend_' in front of the mapping key. + $setting_key_map = substr($setting_key_map, 7, strlen($setting_key_map)); + $value = $config_updater->transformBoolStringValueToBool($config_value); + switch ($setting_key_map) { + case 'layout': + $library_config['legend']['layout'] = $value; + break; + + case 'background_color': + $library_config['legend']['background_color'] = $value; + break; + + case 'border_width': + $library_config['legend']['border_width'] = $value; + break; + + case 'shadow': + $library_config['legend']['shadow'] = $value; + break; + + case 'item_style_color': + $library_config['legend']['item_style']['color'] = $value; + break; + + default: + $library_config['legend']['item_style']['overflow'] = $value; + } + + $new_settings['library_config'] = $library_config; + $highcharts_config->delete(); + } +} diff --git a/web/modules/charts/modules/charts_highcharts/charts_highcharts.post_update.php b/web/modules/charts/modules/charts_highcharts/charts_highcharts.post_update.php new file mode 100644 index 00000000..deb66922 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/charts_highcharts.post_update.php @@ -0,0 +1,33 @@ +getEditable('charts.settings'); + $library_plugin_id = $config->get('charts_default_settings.library'); + $library_is_highcharts = $library_plugin_id === 'highcharts'; + $global_options_config_key = 'charts_default_settings.library_config.global_options'; + // Getting the current config key to ensure that we don't overwrite the + // existing configurations in case the user saved the config before running + // this update. + $global_options = $library_is_highcharts ? $config->get($global_options_config_key) : []; + if ($library_is_highcharts && !$global_options) { + $config->set($global_options_config_key, Highcharts::defaultGlobalOptions()); + return 'Global options config were updated.'; + } + return 'Global options update were not set because the default library is not highcharts or existing global options were saved before running post update.'; +} diff --git a/web/modules/charts/modules/charts_highcharts/composer.json b/web/modules/charts/modules/charts_highcharts/composer.json new file mode 100755 index 00000000..25f71493 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/composer.json @@ -0,0 +1,128 @@ +{ + "name": "drupal/charts_highcharts", + "description": "Charts module integration with Highcharts library.", + "type": "drupal-module", + "homepage": "http://drupal.org/project/charts", + "support": { + "issues": "https://www.drupal.org/project/issues/charts" + }, + "license": "GPL-2.0+", + "require": { + "highcharts/3d": "11.1.0", + "highcharts/accessibility": "11.1.0", + "highcharts/export-data": "11.1.0", + "highcharts/exporting": "11.1.0", + "highcharts/highcharts": "11.1.0", + "highcharts/more": "11.1.0" + }, + "repositories": { + "highcharts": { + "type": "package", + "package": { + "name": "highcharts/highcharts", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/highcharts.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + "highcharts_3d": { + "type": "package", + "package": { + "name": "highcharts/3d", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_3d" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/highcharts-3d.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + "highcharts_accessibility": { + "type": "package", + "package": { + "name": "highcharts/accessibility", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_accessibility" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/modules/accessibility.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + "highcharts_export_data": { + "type": "package", + "package": { + "name": "highcharts/export-data", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_export-data" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/modules/export-data.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + "highcharts_exporting": { + "type": "package", + "package": { + "name": "highcharts/exporting", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_exporting" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/modules/exporting.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + }, + "highcharts_more": { + "type": "package", + "package": { + "name": "highcharts/more", + "version": "11.1.0", + "type": "drupal-library", + "extra": { + "installer-name": "highcharts_more" + }, + "dist": { + "url": "https://code.highcharts.com/11.1.0/highcharts-more.js", + "type": "file" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + } + } + } + } +} diff --git a/web/modules/charts/modules/charts_highcharts/config/schema/charts_highcharts.schema.yml b/web/modules/charts/modules/charts_highcharts/config/schema/charts_highcharts.schema.yml new file mode 100644 index 00000000..ee7b8b20 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/config/schema/charts_highcharts.schema.yml @@ -0,0 +1,149 @@ +charts.settings.library_plugin.highcharts: + type: charts_settings_library_plugin + mapping: + legend: + label: Legend + type: mapping + mapping: + layout: + label: Layout + type: string + background_color: + label: 'Background color' + type: string + border_width: + label: 'Border width' + type: string + item_style: + label: 'Border width' + type: mapping + mapping: + color: + label: Color + type: string + overflow: + label: 'Text overflow' + type: string + shadow: + label: 'Enable legend shadow' + type: boolean + exporting_library: + label: 'Enable exporting library' + type: boolean + texture_library: + label: 'Enable texture library' + type: boolean + global_options: + label: 'Global options' + type: mapping + mapping: + lang: + type: mapping + label: Language + mapping: + download_CSV: + type: label + label: 'Download CSV' + download_JPEG: + type: label + label: 'Download JPEG' + download_PDF: + type: label + label: 'Download PDF' + download_PNG: + type: label + label: 'Download PNG' + download_SVG: + type: label + label: 'Download SVG' + download_XLS: + type: label + label: 'Download XLS' + exit_fullscreen: + type: label + label: 'Exit fullscreen' + hide_data: + type: label + label: 'Hide data' + loading: + type: label + label: 'Loading' + main_breadcrumb: + type: label + label: 'Main breadcrumb' + thousands_sep: + type: label + label: 'Number formatting: Thousand separator' + decimal_point: + type: label + label: 'Number formatting: Decimal point' + no_data: + type: label + label: 'No data' + print_chart: + type: label + label: 'Print chart' + reset_zoom: + type: label + label: 'Reset zoom' + reset_zoom_title: + type: label + label: 'Reset zoom title' + view_data: + type: label + label: 'View data' + view_fullscreen: + type: label + label: 'View fullscreen' + context_button_title: + type: label + label: 'Context button title' + drill_up_text: + type: label + label: 'Drill up text' + invalid_date: + type: label + label: 'Invalid date' + months: + type: sequence + label: Months + sequence: + type: label + label: Month + short_months: + type: sequence + label: 'Short Months' + sequence: + type: label + label: 'Short Month' + weekdays: + type: sequence + label: Weekdays + sequence: + type: label + label: Weekday + short_weekdays: + type: sequence + label: 'Short Weekdays' + sequence: + type: label + label: 'Short Weekday' + export_data: + type: mapping + label: 'Export data' + mapping: + annotation_header: + type: label + label: 'The annotation column title' + category_datetime_header: + type: label + label: 'The category column title when axis type set to "datetime"' + category_header: + type: label + label: 'The category column title' + numeric_symbols: + type: sequence + label: 'Numeric symbols' + sequence: + type: label + label: 'Numeric symbol' diff --git a/web/modules/charts/modules/charts_highcharts/js/charts_highcharts.js b/web/modules/charts/modules/charts_highcharts/js/charts_highcharts.js new file mode 100755 index 00000000..2cb33bd0 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/js/charts_highcharts.js @@ -0,0 +1,40 @@ +/** + * @file + * JavaScript's integration between Highcharts and Drupal. + */ +(function (Drupal, once, drupalSettings) { + + 'use strict'; + + Drupal.behaviors.chartsHighcharts = { + attach: function (context) { + const contents = new Drupal.Charts.Contents(); + if (drupalSettings.hasOwnProperty('charts') && drupalSettings.charts.highcharts.global_options) { + Highcharts.setOptions(drupalSettings.charts.highcharts.global_options); + } + once('charts-highchart', '.charts-highchart', context).forEach(function (element) { + const id = element.id; + let config = contents.getData(id); + if (!config) { + return; + } + + config.chart.renderTo = id; + new Highcharts.Chart(config); + if (element.nextElementSibling && element.nextElementSibling.hasAttribute('data-charts-debug-container')) { + element.nextElementSibling.querySelector('code').innerText = JSON.stringify(config, null, ' '); + } + }); + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + once('charts-highchart-detach', '.charts-highchart', context).forEach(function (element) { + if (!element.dataset.hasOwnProperty('highchartsChart')) { + return; + } + Highcharts.charts[element.dataset.highchartsChart].destroy(); + }); + } + } + }; +}(Drupal, once, drupalSettings)); diff --git a/web/modules/charts/modules/charts_highcharts/js/color_changer.js b/web/modules/charts/modules/charts_highcharts/js/color_changer.js new file mode 100755 index 00000000..1d031d17 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/js/color_changer.js @@ -0,0 +1,42 @@ +/** + * @file + * JavaScript's integration between Highcharts and Drupal. + */ +((Drupal, once) => { + 'use strict'; + + Drupal.behaviors.chartsHighchartsColorChanger = { + attach: function (context) { + const colorChangerHandler = function (event) { + const chartMetadata = JSON.parse(this.dataset.chartsHighchartsColorInfo); + const chartsElement = document.getElementById(chartMetadata.chart_id); + const chart = Highcharts.charts[chartsElement.dataset.highchartsChart]; + switch (chartMetadata.chart_type) { + case 'pie': + chart.series[0].data[chartMetadata.series_index].color = ''; + chart.series[0].data[chartMetadata.series_index].update({ + color: event.target.value + }); + break; + + case 'gauge': + chart.yAxis[0].plotLinesAndBands[0].options.color = event.target.value; + chart.yAxis[0].update(); + break; + + default: + chart.series[chartMetadata.series_index].update({ + color: event.target.value + }); + } + }; + once('charts-color-changer', '.charts-color-changer', context).forEach(function (element) { + element.querySelectorAll('input[type="color"]').forEach( + (colorChanger) => { + colorChanger.addEventListener('change', colorChangerHandler); + } + ); + }); + } + }; +})(Drupal, once); diff --git a/web/modules/charts/modules/charts_highcharts/js/textures.js b/web/modules/charts/modules/charts_highcharts/js/textures.js new file mode 100755 index 00000000..7e747688 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/js/textures.js @@ -0,0 +1,69 @@ +/** + * @file + * Applies textures to Highcharts charts. + */ +(function (Drupal, once) { + + 'use strict'; + Drupal.charts_highcharts = Drupal.charts_highcharts || {}; + + Drupal.behaviors.chartsHighchartsAddTexture = { + attach: function (context) { + once('charts-highchart-texture', '.charts-highchart', context).forEach(function (element) { + element.addEventListener('drupalChartsConfigsInitialization', function (e) { + let data = e.detail; + const id = data.drupalChartDivId; + // Add textures to series. + if ('series' in data && data.series[0].color !== undefined && typeof data.series[0].data[0] === "number") { + for (let i = 0; i < data.series.length; i++) { + data.series[i].color = Drupal.charts_highcharts.getPattern(Drupal.charts_highcharts.getUnderTenIndex(i), data.series[i].color); + } + } else if ('series' in data && typeof data.series[0].data[0] === "object" && data.series[0].data[0].color !== undefined) { + for (let i = 0; i < data.series[0].data.length; i++) { + data.series[0].data[i].color = Drupal.charts_highcharts.getPattern(Drupal.charts_highcharts.getUnderTenIndex(i), data.series[0].data[i].color); + } + } + if ('series' in data && typeof data.series[0].data[0] === "object" && data.series[0].data[0].color === undefined) { + for (let i = 0; i < data.colors.length; i++) { + data.colors[i] = Drupal.charts_highcharts.getPattern(Drupal.charts_highcharts.getUnderTenIndex(i), data.colors[i]); + } + } + + Drupal.Charts.Contents.update(id, data); + }); + }); + } + }; + + /** + * Get under ten index in case there are more than ten series because + * Highcharts patterns array has ten patterns + * 0 - 9 index. + */ + Drupal.charts_highcharts.getUnderTenIndex = function (k) { + if (k < 10) { + return k; + } else { + while (k >= 10) { + // Sum the digits of k. + k = k.toString().split('').reduce(function (a, b) { + return parseInt(a) + parseInt(b); + }); + } + return k; + } + } + + /** + * Get a default pattern, but using the series color. + * The index-argument refers to which default pattern to use + */ + Drupal.charts_highcharts.getPattern = function (index, color) { + return { + pattern: Highcharts.merge(Highcharts.patterns[index], { + color: color + }) + }; + } + +}(Drupal, once)); diff --git a/web/modules/charts/modules/charts_highcharts/src/Form/ColorChanger.php b/web/modules/charts/modules/charts_highcharts/src/Form/ColorChanger.php new file mode 100644 index 00000000..505614fa --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/src/Form/ColorChanger.php @@ -0,0 +1,112 @@ +get('chart_id'); + $series = $form_state->get('chart_series'); + $chart_type = $form_state->get('chart_type'); + $y_axis = $form_state->get('y_axis'); + if (!$series || !$chart_id) { + return $form; + } + $form['#attributes']['class'][] = 'charts-color-changer'; + $form['color_changer_wrapper'] = [ + '#title' => $this->t('Change the colors'), + '#type' => 'fieldset', + '#collapsible' => TRUE, + '#collapsed' => FALSE, + ]; + foreach ($series as $index => $item) { + if ($chart_type === 'pie') { + foreach ($item['data'] as $datum_index => $datum_value) { + $form['color_changer_wrapper']['color_' . $datum_index] = [ + '#type' => 'textfield', + '#title' => $this->t('@label Color', ['@label' => $datum_value['name'] ?? '']), + '#attributes' => [ + 'TYPE' => 'color', + 'style' => 'min-width:50px;', + 'data-charts-highcharts-color-info' => json_encode([ + 'series_index' => $datum_index, + 'chart_id' => $chart_id, + 'chart_type' => $chart_type, + ]), + ], + '#size' => 10, + '#maxlength' => 7, + '#default_value' => $datum_value['color'] ?? '#000', + ]; + } + } + elseif ($chart_type === 'gauge') { + $gauge_colors = [ + 'red' => '#ff0000', + 'yellow' => '#ffff00', + 'green' => '#008000', + ]; + foreach ($y_axis['plotBands'] as $datum_index => $datum_value) { + $form['color_changer_wrapper']['color_' . $datum_index] = [ + '#type' => 'textfield', + '#title' => $this->t('@label Color', ['@label' => $item['name'] . ' (' . $datum_value['color'] . ')']), + '#attributes' => [ + 'TYPE' => 'color', + 'style' => 'min-width:50px;', + 'data-charts-highcharts-color-info' => json_encode([ + 'series_index' => $datum_index, + 'chart_id' => $chart_id, + 'chart_type' => $chart_type, + ]), + ], + '#size' => 10, + '#maxlength' => 7, + '#default_value' => $gauge_colors[$datum_value['color']] ?? '#000', + ]; + } + } + else { + $form['color_changer_wrapper']['color_' . $index] = [ + '#type' => 'textfield', + '#title' => $this->t('@label Color', ['@label' => $item['name']]), + '#attributes' => [ + 'TYPE' => 'color', + 'style' => 'min-width:50px;', + 'data-charts-highcharts-color-info' => json_encode([ + 'series_index' => $index, + 'chart_id' => $chart_id, + ]), + ], + '#size' => 10, + '#maxlength' => 7, + '#default_value' => $item['color'] ?? '#000', + ]; + } + + } + + return $form; + } + + /** + * {@inheritDoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) {} + +} diff --git a/web/modules/charts/modules/charts_highcharts/src/Plugin/chart/Library/Highcharts.php b/web/modules/charts/modules/charts_highcharts/src/Plugin/chart/Library/Highcharts.php new file mode 100755 index 00000000..d8d7dacd --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/src/Plugin/chart/Library/Highcharts.php @@ -0,0 +1,1079 @@ +elementInfo = $element_info; + $this->chartTypeManager = $chart_type_manager; + $this->formBuilder = $form_builder; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('element_info'), + $container->get('plugin.manager.charts_type'), + $container->get('form_builder'), + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + $configurations = [ + 'legend' => [ + 'layout' => NULL, + 'background_color' => '', + 'border_width' => 0, + 'shadow' => FALSE, + 'item_style' => [ + 'color' => '', + 'overflow' => '', + ], + ], + 'exporting_library' => TRUE, + 'texture_library' => TRUE, + 'global_options' => static::defaultGlobalOptions(), + ] + parent::defaultConfiguration(); + + return $configurations; + } + + /** + * Build configurations. + * + * @param array $form + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * Return the form. + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + $form['intro_text'] = [ + '#markup' => $this->t('

Charts is designed to be generic enough to work with multiple charting libraries. If you would like settings that apply to all Highcharts charts, you can submit a ticket to have a setting added here, in the Highcharts-specific settings.

'), + ]; + + $form['exporting_library'] = [ + '#title' => $this->t('Enable Highcharts\' "Exporting" library'), + '#type' => 'checkbox', + '#default_value' => !empty($this->configuration['exporting_library']), + ]; + + $form['texture_library'] = [ + '#title' => $this->t('Enable Highcharts\' "Texture" library'), + '#type' => 'checkbox', + '#default_value' => !empty($this->configuration['texture_library']), + ]; + + $legend_configuration = $this->configuration['legend'] ?? []; + $form['legend'] = [ + '#title' => $this->t('Legend Settings'), + '#type' => 'fieldset', + ]; + $form['legend']['layout'] = [ + '#title' => $this->t('Legend layout'), + '#type' => 'select', + '#options' => [ + 'vertical' => $this->t('Vertical'), + 'horizontal' => $this->t('Horizontal'), + ], + '#default_value' => $legend_configuration['layout'] ?? NULL, + ]; + $form['legend']['background_color'] = [ + '#title' => $this->t('Legend background color'), + '#type' => 'textfield', + '#size' => 10, + '#maxlength' => 7, + '#attributes' => ['placeholder' => $this->t('transparent')], + '#description' => $this->t('Leave blank for a transparent background.'), + '#default_value' => $legend_configuration['background_color'] ?? '', + ]; + $form['legend']['border_width'] = [ + '#title' => $this->t('Legend border width'), + '#type' => 'select', + '#options' => [ + 0 => $this->t('None'), + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + ], + '#default_value' => $legend_configuration['border_width'] ?? 0, + ]; + $form['legend']['shadow'] = [ + '#title' => $this->t('Enable legend shadow'), + '#type' => 'checkbox', + '#default_value' => !empty($legend_configuration['shadow']), + ]; + $form['legend']['item_style'] = [ + '#title' => $this->t('Item Style'), + '#type' => 'fieldset', + ]; + $form['legend']['item_style']['color'] = [ + '#title' => $this->t('Item style color'), + '#type' => 'textfield', + '#size' => 10, + '#maxlength' => 7, + '#attributes' => ['placeholder' => '#333333'], + '#description' => $this->t('Leave blank for a dark gray font.'), + '#default_value' => $legend_configuration['item_style']['color'] ?? '', + ]; + $form['legend']['item_style']['overflow'] = [ + '#title' => $this->t('Text overflow'), + '#type' => 'select', + '#options' => [ + '' => $this->t('No'), + 'ellipsis' => $this->t('Ellipsis'), + ], + '#default_value' => $legend_configuration['item_style']['overflow'] ?? '', + ]; + + $form['global_options'] = [ + '#title' => $this->t('Global options'), + '#type' => 'details', + '#collapsible' => TRUE, + '#tree' => TRUE, + ]; + $form['global_options']['lang'] = [ + '#title' => $this->t('Language'), + '#type' => 'details', + '#collapsible' => TRUE, + '#tree' => TRUE, + ]; + // Download menu item. + $lang_config = $this->defaultConfiguration()['global_options']['lang']; + foreach (array_keys($lang_config) as $property) { + if (strpos($property, 'download_') !== 0) { + continue; + } + + [, $format] = explode('_', $property); + $form['global_options']['lang'][$property] = [ + '#title' => $this->t('Download @format', ['@format' => $format]), + '#type' => 'textfield', + '#description' => $this->t('The text for the @format download menu item.', ['@format' => $format]), + '#default_value' => $this->configuration['global_options']['lang'][$property] ?? $lang_config[$property], + '#required' => TRUE, + ]; + } + // Other simple string configs. + $form['global_options']['lang']['exit_fullscreen'] = [ + '#title' => $this->t('Exit fullscreen'), + '#type' => 'textfield', + '#description' => $this->t('Exporting module only. The text for the menu item to exit the chart from full screen.'), + '#default_value' => $this->configuration['global_options']['lang']['exit_fullscreen'] ?? $lang_config['exit_fullscreen'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['hide_data'] = [ + '#title' => $this->t('Hide data'), + '#type' => 'textfield', + '#description' => $this->t('The text for the menu item.'), + '#default_value' => $this->configuration['global_options']['lang']['hide_data'] ?? $lang_config['hide_data'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['loading'] = [ + '#title' => $this->t('Loading'), + '#type' => 'textfield', + '#description' => $this->t('The loading text that appears when the chart is set into the loading state following a call to chart.showLoading.'), + '#default_value' => $this->configuration['global_options']['lang']['loading'] ?? $lang_config['loading'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['main_breadcrumb'] = [ + '#title' => $this->t('Main breadcrumb'), + '#type' => 'textfield', + '#default_value' => $this->configuration['global_options']['lang']['main_breadcrumb'] ?? $lang_config['main_breadcrumb'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['thousands_sep'] = [ + '#title' => $this->t('Number formatting: Thousand separator'), + '#type' => 'textfield', + '#default_value' => $this->configuration['global_options']['lang']['thousands_sep'] ?? $lang_config['thousands_sep'], + ]; + $form['global_options']['lang']['decimal_point'] = [ + '#title' => $this->t('Number formatting: Decimal point'), + '#type' => 'textfield', + '#default_value' => $this->configuration['global_options']['lang']['decimal_point'] ?? $lang_config['decimal_point'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['no_data'] = [ + '#title' => $this->t('No data'), + '#type' => 'textfield', + '#description' => $this->t('The text to display when the chart contains no data.'), + '#default_value' => $this->configuration['global_options']['lang']['no_data'] ?? $lang_config['no_data'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['print_chart'] = [ + '#title' => $this->t('Print chart'), + '#type' => 'textfield', + '#description' => $this->t('Exporting module only. The text for the menu item to print the chart.'), + '#default_value' => $this->configuration['global_options']['lang']['print_chart'] ?? $lang_config['print_chart'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['reset_zoom'] = [ + '#title' => $this->t('Reset zoom'), + '#type' => 'textfield', + '#description' => $this->t('The text for the label appearing when a chart is zoomed.'), + '#default_value' => $this->configuration['global_options']['lang']['reset_zoom'] ?? $lang_config['reset_zoom'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['reset_zoom_title'] = [ + '#title' => $this->t('Reset zoom title'), + '#type' => 'textfield', + '#description' => $this->t('The tooltip title for the label appearing when a chart is zoomed.'), + '#default_value' => $this->configuration['global_options']['lang']['reset_zoom_title'] ?? $lang_config['reset_zoom_title'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['view_data'] = [ + '#title' => $this->t('View data'), + '#type' => 'textfield', + '#description' => $this->t('The text for the menu item.'), + '#default_value' => $this->configuration['global_options']['lang']['view_data'] ?? $lang_config['view_data'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['view_fullscreen'] = [ + '#title' => $this->t('View fullscreen'), + '#type' => 'textfield', + '#description' => $this->t('Exporting module only. The text for the menu item to view the chart in full screen.'), + '#default_value' => $this->configuration['global_options']['lang']['view_fullscreen'] ?? $lang_config['view_fullscreen'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['context_button_title'] = [ + '#title' => $this->t('Context button title'), + '#type' => 'textfield', + '#description' => $this->t('Exporting module menu. The tooltip title for the context menu holding print and export menu items.'), + '#default_value' => $this->configuration['global_options']['lang']['context_button_title'] ?? $lang_config['context_button_title'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['drill_up_text'] = [ + '#title' => $this->t('Drill up text'), + '#type' => 'textfield', + '#description' => $this->t('The text for the button that appears when drilling down, linking back to the parent series.'), + '#default_value' => $this->configuration['global_options']['lang']['drill_up_text'] ?? $lang_config['drill_up_text'], + '#required' => TRUE, + ]; + $form['global_options']['lang']['invalid_date'] = [ + '#title' => $this->t('Invalid date'), + '#type' => 'textfield', + '#description' => $this->t('What to show in a date field for invalid dates.'), + '#default_value' => $this->configuration['global_options']['lang']['invalid_date'] ?? $lang_config['invalid_date'], + '#required' => TRUE, + ]; + // Dates related global options. + foreach ($this->datesDataForConfigForm() as $dates_data_key => $data) { + $form['global_options']['lang'][$dates_data_key] = [ + '#type' => 'details', + '#title' => $data['label_plural'], + '#description' => $data['description'], + '#collapsible' => TRUE, + '#tree' => TRUE, + ]; + foreach (range(0, $data['range_end']) as $counter) { + $form['global_options']['lang'][$dates_data_key][$counter] = [ + '#title' => $this->t('@label_singular', [ + '@label_singular' => $data['label_singular'], + ]) . ' ' . ($counter + 1), + '#type' => 'textfield', + '#default_value' => $this->configuration['global_options']['lang'][$dates_data_key][$counter] ?? $lang_config[$dates_data_key][$counter], + '#required' => TRUE, + ]; + } + } + // Export data related global options. + $form['global_options']['lang']['export_data'] = [ + '#title' => $this->t('Export data'), + '#type' => 'details', + '#collapsible' => TRUE, + '#tree' => TRUE, + ]; + foreach ($this->exportDataForConfigForm() as $export_data_key => $data) { + $form['global_options']['lang']['export_data'][$export_data_key] = [ + '#title' => $this->t('@label', [ + '@label' => $data['label'], + ]), + '#type' => 'textfield', + '#description' => $this->t('@description', [ + '@description' => $data['description'], + ]), + '#default_value' => $this->configuration['global_options']['lang']['export_data'][$export_data_key] ?? $lang_config['export_data'][$export_data_key], + '#required' => TRUE, + ]; + } + // Numeric symbols related global options. + $form['global_options']['lang']['numeric_symbols'] = [ + '#type' => 'details', + '#title' => 'Numeric symbols', + '#description' => 'The numeric symbols.', + '#collapsible' => TRUE, + '#tree' => TRUE, + ]; + foreach (range(0, 5) as $counter) { + $form['global_options']['lang']['numeric_symbols'][$counter] = [ + '#title' => $this->t('@label_singular', [ + '@label_singular' => 'Numeric symbol', + ]) . ' ' . ($counter + 1), + '#type' => 'textfield', + '#default_value' => $this->configuration['global_options']['lang']['numeric_symbols'][$counter] ?? $lang_config['numeric_symbols'][$counter], + '#required' => TRUE, + ]; + } + + return $form; + } + + /** + * Submit configurations. + * + * @param array $form + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + + if (!$form_state->getErrors()) { + $values = $form_state->getValue($form['#parents']); + $this->configuration['legend'] = $values['legend']; + $this->configuration['exporting_library'] = $values['exporting_library']; + $this->configuration['texture_library'] = $values['texture_library']; + $this->configuration['global_options'] = $values['global_options']; + } + } + + /** + * {@inheritdoc} + */ + public function preRender(array $element) { + // Populate chart settings. + $chart_definition = []; + + $chart_definition = $this->populateOptions($element, $chart_definition); + $chart_definition = $this->populateAxes($element, $chart_definition); + $chart_definition = $this->populateData($element, $chart_definition); + + if (!empty($element['#height']) || !empty($element['#width'])) { + $element['#attributes']['style'] = 'height:' . $element['#height'] . $element['#height_units'] . ';width:' . $element['#width'] . $element['#width_units'] . ';'; + } + + // Remove machine names from series. Highcharts series must be an array. + $series = !empty($chart_definition['series']) ? array_values($chart_definition['series']) : []; + unset($chart_definition['series']); + + // Trim out empty options (excluding "series" for efficiency). + ChartElement::trimArray($chart_definition); + + // Put back the data. + $chart_definition['series'] = $series; + + if (!isset($element['#id'])) { + $element['#id'] = Html::getUniqueId('highchart-render'); + } + + $element['#attached']['library'][] = 'charts_highcharts/highcharts'; + if (!empty($this->configuration['exporting_library'])) { + $element['#attached']['library'][] = 'charts_highcharts/highcharts_exporting'; + } + if (!empty($this->configuration['texture_library'])) { + $element['#attached']['library'][] = 'charts_highcharts/texture'; + } + $element['#attributes']['class'][] = 'charts-highchart'; + $element['#chart_definition'] = $chart_definition; + // Show a form on the front-end so users can change chart colors. + if (!empty($element['#color_changer']) && empty($element['#in_preview_mode'])) { + $form_state = new FormState(); + $form_state->set('chart_series', $series); + $form_state->set('chart_id', $element['#id']); + $form_state->set('chart_type', $chart_definition['chart']['type']); + if (!empty($chart_definition['yAxis'])) { + $form_state->set('y_axis', $chart_definition['yAxis']); + } + $element['#attached']['library'][] = 'charts_highcharts/color_changer'; + $element['#content_suffix']['color_changer'] = $this->formBuilder->buildForm(ColorChanger::class, $form_state); + } + + // Setting global options. + $element['#attached']['drupalSettings']['charts']['highcharts']['global_options'] = $this->processedGlobalOptions(); + + return $element; + } + + /** + * Defines the default global options. + */ + public static function defaultGlobalOptions() { + return [ + 'lang' => [ + 'download_CSV' => 'Download CSV', + 'download_JPEG' => 'Download JPEG image', + 'download_PDF' => 'Download PDF document', + 'download_PNG' => 'Download PNG image ', + 'download_SVG' => 'Download SVG vector image', + 'download_XLS' => 'Download XLS', + 'exit_fullscreen' => 'Exit from full screen', + 'hide_data' => 'Hide data table', + 'loading' => 'Loading...', + 'main_breadcrumb' => 'Main', + 'thousands_sep' => ' ', + 'decimal_point' => '.', + 'no_data' => 'No data to display', + 'print_chart' => 'Print chart', + 'reset_zoom' => 'Reset zoom', + 'reset_zoom_title' => 'Reset zoom level 1:1', + 'view_data' => 'View data table', + 'view_fullscreen' => 'View in full screen', + 'context_button_title' => 'Chart context menu', + 'drill_up_text' => 'Back to {series.name}', + 'invalid_date' => 'Invalid date', + 'months' => [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + 'short_months' => [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sept', + 'Oct', + 'Nov', + 'Dec', + ], + 'weekdays' => [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ], + 'short_weekdays' => [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thurs', + 'Frid', + 'Sat', + ], + 'export_data' => [ + 'annotation_header' => 'Annotations', + 'category_datetime_header' => 'DateTime', + 'category_header' => 'Category', + ], + 'numeric_symbols' => [ + 'k', + 'M', + 'G', + 'T', + 'P', + 'E', + ], + ], + ]; + } + + /** + * Populate options. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + protected function populateOptions(array $element, array $chart_definition) { + $chart_type = $this->getType($element['#chart_type']); + $chart_definition['chart']['type'] = $chart_type; + $chart_definition['chart']['backgroundColor'] = $element['#background']; + $chart_definition['chart']['polar'] = $element['#polar'] ?? NULL; + $chart_definition['chart']['options3d']['enabled'] = $element['#three_dimensional'] ?? NULL; + $chart_definition['credits']['enabled'] = FALSE; + $chart_definition['title']['text'] = $element['#title'] ?? ''; + $chart_definition['title']['style']['color'] = $element['#title_color']; + $title_position = $element['#title_position'] ?? ''; + $chart_definition['title']['align'] = in_array($title_position, [ + 'left', + 'center', + 'right', + ]) ? $title_position : NULL; + $chart_definition['title']['verticalAlign'] = in_array($title_position, [ + 'top', + 'bottom', + ]) ? $title_position : NULL; + $chart_definition['title']['y'] = $title_position === 'in' ? 24 : NULL; + $chart_definition['subtitle']['text'] = $element['#subtitle'] ?? ''; + $chart_definition['subtitle']['align'] = in_array($title_position, [ + 'left', + 'center', + 'right', + ]) ? $title_position : NULL; + $chart_definition['subtitle']['verticalAlign'] = in_array($title_position, [ + 'top', + 'bottom', + ]) ? $title_position : NULL; + if (!empty($element['#subtitle']) && !empty($title_position)) { + if ($title_position === 'in') { + $chart_definition['subtitle']['y'] = 42; + } + elseif ($title_position === 'bottom') { + $chart_definition['subtitle']['y'] = 26; + } + } + $chart_definition['colors'] = $element['#colors']; + $chart_definition['tooltip']['enabled'] = (bool) $element['#tooltips']; + $chart_definition['tooltip']['useHTML'] = (bool) $element['#tooltips_use_html']; + $chart_definition['plotOptions']['series']['stacking'] = $element['#stacking'] ?? ''; + $chart_definition['plotOptions']['series']['dataLabels']['enabled'] = (bool) $element['#data_labels']; + $chart_definition['plotOptions']['series']['marker']['enabled'] = (bool) $element['#data_markers']; + if ($element['#chart_type'] === 'gauge') { + $chart_definition['yAxis']['plotBands'][] = [ + 'from' => $element['#gauge']['red_from'], + 'to' => $element['#gauge']['red_to'], + 'color' => 'red', + ]; + $chart_definition['yAxis']['plotBands'][] = [ + 'from' => $element['#gauge']['yellow_from'], + 'to' => $element['#gauge']['yellow_to'], + 'color' => 'yellow', + ]; + $chart_definition['yAxis']['plotBands'][] = [ + 'from' => $element['#gauge']['green_from'], + 'to' => $element['#gauge']['green_to'], + 'color' => 'green', + ]; + $chart_definition['yAxis']['min'] = (int) $element['#gauge']['min']; + $chart_definition['yAxis']['max'] = (int) $element['#gauge']['max']; + } + + // These changes are for consistency with Google. Perhaps too specific? + if ($element['#chart_type'] === 'pie') { + $chart_definition['plotOptions']['pie']['dataLabels']['distance'] = -30; + $chart_definition['plotOptions']['pie']['dataLabels']['color'] = 'white'; + $chart_definition['plotOptions']['pie']['dataLabels']['format'] = '{percentage:.1f}%'; + + $chart_definition['tooltip']['pointFormat'] = '{point.y} ({point.percentage:.1f}%)
'; + } + + if ($element['#legend'] === TRUE) { + $chart_definition['legend']['enabled'] = $element['#legend']; + if (in_array($element['#chart_type'], ['pie', 'donut'])) { + $chart_definition['plotOptions']['pie']['showInLegend'] = TRUE; + } + elseif ($element['#chart_type'] == 'gauge') { + $chart_definition['plotOptions']['gauge']['showInLegend'] = TRUE; + } + if (!empty($element['#legend_title'])) { + $chart_definition['legend']['title']['text'] = $element['#legend_title']; + } + + if ($element['#legend_position'] === 'bottom') { + $chart_definition['legend']['verticalAlign'] = 'bottom'; + $chart_definition['legend']['layout'] = 'horizontal'; + } + elseif ($element['#legend_position'] === 'top') { + $chart_definition['legend']['verticalAlign'] = 'top'; + $chart_definition['legend']['layout'] = 'horizontal'; + } + else { + $chart_definition['legend']['align'] = $element['#legend_position']; + $chart_definition['legend']['verticalAlign'] = 'middle'; + $chart_definition['legend']['layout'] = 'vertical'; + } + + // Setting more legend configuration based on the plugin form entry. + $legend_configuration = $this->configuration['legend'] ?? []; + if (!empty($legend_configuration['layout'])) { + $chart_definition['legend']['layout'] = $legend_configuration['layout']; + } + if (!empty($legend_configuration['background_color'])) { + $chart_definition['legend']['backgroundColor'] = $legend_configuration['background_color']; + } + if (!empty($legend_configuration['border_width'])) { + $chart_definition['legend']['borderWidth'] = $legend_configuration['border_width']; + } + if (!empty($legend_configuration['shadow'])) { + $chart_definition['legend']['shadow'] = TRUE; + } + if (!empty($legend_configuration['item_style']['color'])) { + $chart_definition['legend']['itemStyle']['color'] = $legend_configuration['item_style']['color']; + } + if (!empty($legend_configuration['item_style']['overflow'])) { + $chart_definition['legend']['itemStyle']['overflow'] = $legend_configuration['item_style']['overflow']; + } + } + else { + $chart_definition['legend']['enabled'] = FALSE; + } + + // Merge in chart raw options. + if (!empty($element['#raw_options'])) { + $chart_definition = NestedArray::mergeDeepArray([ + $chart_definition, + $element['#raw_options'], + ]); + } + + return $chart_definition; + } + + /** + * Utility to populate data. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + protected function populateData(array &$element, array $chart_definition) { + $categories = []; + $chart_type = $this->getType($element['#chart_type']); + foreach (Element::children($element) as $key) { + if ($element[$key]['#type'] === 'chart_xaxis' && !empty($element[$key]['#labels'])) { + if ($chart_type === 'pie') { + $categories = $element[$key]['#labels']; + break; + } + $categories[] = $element[$key]['#labels']; + } + } + foreach (Element::children($element) as $key) { + if ($element[$key]['#type'] === 'chart_data') { + $series = []; + $series_data = []; + + // Make sure defaults are loaded. + if (empty($element[$key]['#defaults_loaded'])) { + $element[$key] += $this->elementInfo->getInfo($element[$key]['#type']); + } + + // Convert target named axis keys to integers. + if (isset($element[$key]['#target_axis'])) { + $axis_name = $element[$key]['#target_axis']; + $axis_index = 0; + foreach (Element::children($element) as $axis_key) { + if ($element[$axis_key]['#type'] === 'chart_yaxis') { + if ($axis_key === $axis_name) { + break; + } + $axis_index++; + } + } + $series['yAxis'] = $axis_index; + } + + // Allow data to provide the labels. + // This will override the axis settings. + if ($element[$key]['#labels'] && !in_array($element[$key]['#chart_type'], [ + 'scatter', + 'bubble', + ])) { + foreach ($element[$key]['#labels'] as $label_index => $label) { + $series_data[$label_index][0] = $label; + } + } + elseif (!empty($categories) && $chart_type === 'pie') { + foreach ($categories as $label_index => $label) { + $series_data[$label_index][0] = $label; + } + } + + // Populate the data. + foreach ($element[$key]['#data'] as $data_index => $data) { + if (isset($series_data[$data_index])) { + $series_data[$data_index][] = $data; + } + elseif ($chart_type === 'pie') { + $series_data[$data_index] = $data; + $name = $series_data[$data_index]['name'] ?? NULL; + if (!empty($element[$key]['#grouping_colors'][$data_index][$name])) { + $series_data[$data_index]['color'] = $element[$key]['#grouping_colors'][$data_index][$name]; + } + elseif (!empty($element[$key]['#grouping_colors'][$data_index]) && is_array($element[$key]['#grouping_colors'][$data_index])) { + $chart_definition['colors'][$data_index] = reset($element[$key]['#grouping_colors'][$data_index]); + } + } + else { + $series_data[$data_index] = $data; + } + } + + $series['type'] = $element[$key]['#chart_type']; + if ($element['#chart_type'] === 'donut') { + // Add innerSize to differentiate between donut and pie. + $series['innerSize'] = '40%'; + } + $series['name'] = $element[$key]['#title']; + $series['color'] = $element[$key]['#color']; + + if ($element[$key]['#prefix'] || $element[$key]['#suffix']) { + $yaxis_index = $series['yAxis'] ?? 0; + // For axis formatting, we need to use a format string. + // See http://docs.highcharts.com/#formatting. + $decimal_formatting = $element[$key]['#decimal_count'] ? (':.' . $element[$key]['#decimal_count'] . 'f') : ''; + $chart_definition['yAxis'][$yaxis_index]['labels']['format'] = $element[$key]['#prefix'] . "{value$decimal_formatting}" . $element[$key]['#suffix']; + } + + // Remove unnecessary keys to trim down the resulting JS settings. + ChartElement::trimArray($series); + + // If you want a different type of scatter. + if (!empty($element['#alternative_scatter'])) { + $series = $series_data; + } + else { + $series['data'] = $series_data; + } + + // Merge in series raw options. + if (!empty($element[$key]['#raw_options'])) { + $series = NestedArray::mergeDeepArray([ + $series, + $element[$key]['#raw_options'], + ]); + } + + // Add the series to the main chart definition. + // Scatter colors adjustment. + if (!empty($element['#alternative_scatter'])) { + $chart_definition['series'] = $series; + } + else { + $chart_definition['series'][$key] = $series; + } + + // Merge in any point-specific data points. + foreach (Element::children($element[$key]) as $sub_key) { + if ($element[$key][$sub_key]['#type'] === 'chart_data_item') { + // Make sure defaults are loaded. + if (empty($element[$key][$sub_key]['#defaults_loaded'])) { + $element[$key][$sub_key] += $this->elementInfo->getInfo($element[$key][$sub_key]['#type']); + } + + $data_item = $element[$key][$sub_key]; + $series_point = &$chart_definition['series'][$key]['data'][$sub_key]; + + // Convert the point from a simple data value to a complex point. + if (!isset($series_point['data'])) { + $data = $series_point; + $series_point = []; + if (is_array($data)) { + $series_point['name'] = $data[0]; + $series_point['y'] = $data[1]; + } + else { + $series_point['y'] = $data; + } + } + if (isset($data_item['#data'])) { + if (is_array($data_item['#data'])) { + $series_point['x'] = $data_item['#data'][0]; + $series_point['y'] = $data_item['#data'][1]; + } + else { + $series_point['y'] = $data_item['#data']; + } + } + if ($data_item['#title']) { + $series_point['name'] = $data_item['#title']; + } + + // Setting the color requires several properties for consistency. + $series_point['color'] = $data_item['#color']; + $series_point['fillColor'] = $data_item['#color']; + $series_point['states']['hover']['fillColor'] = $data_item['#color']; + $series_point['states']['select']['fillColor'] = $data_item['#color']; + ChartElement::trimArray($series_point); + + // Merge in point raw options. + if (!empty($data_item['#raw_options'])) { + $series_point = NestedArray::mergeDeepArray([ + $series_point, + $data_item['#raw_options'], + ]); + } + } + } + } + } + + return $chart_definition; + } + + /** + * Populate axes. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + protected function populateAxes(array $element, array $chart_definition) { + foreach (Element::children($element) as $key) { + if ($element[$key]['#type'] === 'chart_xaxis' || $element[$key]['#type'] === 'chart_yaxis') { + // Make sure defaults are loaded. + if (empty($element[$key]['#defaults_loaded'])) { + $element[$key] += $this->elementInfo->getInfo($element[$key]['#type']); + } + + // Populate the chart data. + $axis_type = $element[$key]['#type'] === 'chart_xaxis' ? 'xAxis' : 'yAxis'; + $axis = []; + $axis['type'] = $element[$key]['#axis_type']; + $axis['title']['text'] = $element[$key]['#title']; + $axis['title']['style']['color'] = $element[$key]['#title_color']; + if (!empty($element[$key]['#labels'])) { + $axis['categories'] = $element[$key]['#labels']; + } + $axis['labels']['style']['color'] = $element[$key]['#labels_color']; + $axis['labels']['rotation'] = $element[$key]['#labels_rotation']; + $axis['gridLineColor'] = $element[$key]['#grid_line_color']; + $axis['lineColor'] = $element[$key]['#base_line_color']; + $axis['minorGridLineColor'] = $element[$key]['#minor_grid_line_color']; + $axis['endOnTick'] = isset($element[$key]['#max']) ? FALSE : NULL; + $axis['max'] = $element[$key]['#max']; + $axis['min'] = $element[$key]['#min']; + $axis['opposite'] = $element[$key]['#opposite']; + + if ($axis['labels']['rotation']) { + $chart_type = $this->chartTypeManager->getDefinition($element['#chart_type']); + if ($axis_type === 'xAxis' && !$chart_type['axis_inverted']) { + $axis['labels']['align'] = 'left'; + } + elseif ($axis_type === 'yAxis' && $chart_type['axis_inverted']) { + $axis['labels']['align'] = 'left'; + } + } + + // Merge in axis raw options. + if (!empty($element[$key]['#raw_options'])) { + $axis = NestedArray::mergeDeepArray([ + $axis, + $element[$key]['#raw_options'], + ]); + } + + $chart_definition[$axis_type][] = $axis; + } + } + + return $chart_definition; + } + + /** + * The chart type. + * + * @param string $type + * The chart type. + * + * @return string + * Return the chart type. + */ + protected function getType($type) { + return $type === 'donut' ? 'pie' : $type; + } + + /** + * Defines data for the config form. + */ + private function datesDataForConfigForm() { + $month = [ + 'label_singular' => 'Month', + 'label_plural' => $this->t('Months'), + 'description' => $this->t('The full month names.'), + 'range_end' => 11, + ]; + $weekday = [ + 'label_singular' => 'Weekday', + 'label_plural' => $this->t('Weekdays'), + 'description' => $this->t('The weekday names, starting Sunday.'), + 'range_end' => 6, + ]; + return [ + 'months' => $month, + 'short_months' => [ + 'label_plural' => $this->t('Short Months'), + 'label_singular' => 'Short Month', + 'description' => $this->t('The months names in abbreviated form. E.g. Jan, Feb, etc.'), + ] + $month, + 'weekdays' => $weekday, + 'short_weekdays' => [ + 'label_plural' => $this->t('Short Weekdays'), + 'label_singular' => 'Short Weekday', + 'description' => $this->t('Short week days, starting Sunday. E.g. Sun, Mon, etc.'), + ] + $weekday, + ]; + } + + /** + * Defines data for export data options. + */ + private function exportDataForConfigForm() { + return [ + 'annotation_header' => [ + 'label' => 'Annotation header', + 'description' => 'The annotation column title.', + ], + 'category_datetime_header' => [ + 'label' => 'Category datetime header', + 'description' => 'The category column title when axis type set to "datetime"', + ], + 'category_header' => [ + 'label' => 'Category Header', + 'description' => 'The category column title.', + ], + ]; + } + + /** + * Returns the transformed global options. + */ + private function processedGlobalOptions() { + $global_options = $this->configuration['global_options'] ?? ['lang' => []]; + $global_options['lang'] += static::defaultGlobalOptions()['lang']; + $language_options = &$global_options['lang']; + foreach ($language_options as $option_key => $value) { + if (strpos($option_key, 'download_') === 0) { + $transformed_key = str_replace('_', '', $option_key); + } + else { + $transformed_key = $this->transformSnakeCaseToCamelCase($option_key); + if ($option_key === 'export_data' && is_array($value)) { + foreach ($value as $export_data_key => $export_data_value) { + unset($value[$export_data_key]); + $export_data_key = $this->transformSnakeCaseToCamelCase($export_data_key); + $value[$export_data_key] = $export_data_value; + } + } + } + if ($transformed_key === $option_key) { + continue; + } + + $language_options[$transformed_key] = $value; + unset($language_options[$option_key]); + } + return $global_options; + } + + /** + * Transform the string from snakeCase to CamelCase. + */ + private function transformSnakeCaseToCamelCase(string $input) { + $separator = '_'; + $input = strtolower($input); + if (strpos($input, $separator) === FALSE) { + return $input; + } + return lcfirst(str_replace($separator, '', ucwords($input, $separator))); + } + +} diff --git a/web/modules/charts/modules/charts_highcharts/tests/src/Kernel/RawOptionsTest.php b/web/modules/charts/modules/charts_highcharts/tests/src/Kernel/RawOptionsTest.php new file mode 100644 index 00000000..f42909b8 --- /dev/null +++ b/web/modules/charts/modules/charts_highcharts/tests/src/Kernel/RawOptionsTest.php @@ -0,0 +1,62 @@ + 'chart_data', + '#title' => '5.0.x', + '#data' => [257, 235, 325, 340], + '#color' => '#1d84c3', + ]; + $element = [ + '#type' => 'chart', + '#chart_type' => 'column', + 'series' => $series, + '#raw_options' => [ + 'plotOptions' => [ + 'series' => ['grouping' => TRUE], + ], + ], + ]; + + // Testing raw options when there is NO data already set in the definition. + $path = ['plotOptions', 'series', 'grouping']; + $this->assertJsonPropertyHasValue($element, $path, TRUE); + + // Testing raw options when there is data already set in the definition. + $element['#data_labels'] = TRUE; + $element['#raw_options'] = [ + 'plotOptions' => [ + 'series' => [ + 'dataLabels' => [ + 'enabled' => FALSE, + ], + ], + ], + ]; + $path = ['plotOptions', 'series', 'dataLabels', 'enabled']; + $this->assertJsonPropertyHasValue($element, $path, FALSE); + } + +} diff --git a/web/modules/charts/src/Annotation/Chart.php b/web/modules/charts/src/Annotation/Chart.php new file mode 100755 index 00000000..8a82c837 --- /dev/null +++ b/web/modules/charts/src/Annotation/Chart.php @@ -0,0 +1,35 @@ +alterInfo('charts_chart_library'); + } + +} diff --git a/web/modules/charts/src/ChartViewsFieldInterface.php b/web/modules/charts/src/ChartViewsFieldInterface.php new file mode 100644 index 00000000..b0c63e4b --- /dev/null +++ b/web/modules/charts/src/ChartViewsFieldInterface.php @@ -0,0 +1,18 @@ +entityTypeManager = $entity_type_manager; + $this->moduleHandler = $module_handler; + $this->moduleExtensionList = $extension_list_module; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('module_handler'), + $container->get('extension.list.module') + ); + } + + /** + * Transforms legacy settings to newer setting architecture. + * + * @param array $old_settings + * The old settings. + * @param string $for + * The configuration for which the transformation is being done. + * Regular config or view. + * + * @return array + * The new format settings. + */ + public function transformVersion3SettingsToNew(array &$old_settings, string $for = 'config') { + $new_settings = []; + $new_settings['fields']['stacking'] = !empty($old_settings['grouping']); + $old_config_keys = $this->getLegacySettingsMappingKeys(); + foreach ($old_settings as $setting_id => $setting_value) { + if (empty($old_config_keys[$setting_id])) { + continue; + } + + $setting_key_map = $old_config_keys[$setting_id]; + $value = $this->transformBoolStringValueToBool($setting_value); + // When a block setting belongs to the chart blocks we save it in a + // new setting. + if (substr($setting_key_map, 0, 7) === 'display') { + // Stripping the 'display_' in front of the mapping key. + $setting_key_map = substr($setting_key_map, 8, strlen($setting_key_map)); + if (substr($setting_key_map, 0, 10) === 'dimensions') { + // Stripping dimensions_. + $setting_key_map = substr($setting_key_map, 11, strlen($setting_key_map)); + $new_settings['display']['dimensions'][$setting_key_map] = $value; + } + elseif (substr($setting_key_map, 0, 5) === 'gauge') { + // Stripping gauge_. + $setting_key_map = substr($setting_key_map, 6, strlen($setting_key_map)); + $new_settings['display']['gauge'][$setting_key_map] = $value; + } + else { + $new_settings['display'][$setting_key_map] = $value; + } + } + elseif (substr($setting_key_map, 0, 5) === 'xaxis') { + // Stripping xaxis_. + $setting_key_map = substr($setting_key_map, 6, strlen($setting_key_map)); + $new_settings['xaxis'][$setting_key_map] = $value; + } + elseif (substr($setting_key_map, 0, 5) === 'yaxis') { + // Stripping yaxis_. + $setting_key_map = substr($setting_key_map, 6, strlen($setting_key_map)); + if (substr($setting_key_map, 0, 9) === 'secondary') { + // Stripping gauge_. + $setting_key_map = substr($setting_key_map, 10, strlen($setting_key_map)); + $new_settings['yaxis']['secondary'][$setting_key_map] = $value; + } + else { + $new_settings['yaxis'][$setting_key_map] = $value; + } + } + elseif (substr($setting_key_map, 0, 6) === 'fields') { + // Stripping fields_. + $setting_key_map = substr($setting_key_map, 7, strlen($setting_key_map)); + if ($setting_key_map === 'data_providers' && is_array($value)) { + $data_providers = $new_settings['fields']['data_providers'] ?? []; + if ($setting_id === 'data_fields' || $setting_id == 'field_colors') { + $new_settings['fields']['data_providers'] = $this->transformLegacyFieldsDataProvidersToNew($data_providers, $value); + } + } + else { + $new_settings['fields'][$setting_key_map] = $value; + } + } + elseif ($setting_key_map === 'grouping' && $new_settings['fields']['stacking']) { + $new_settings[$setting_key_map] = []; + } + else { + // We make sure that we handle the color unneeded array. + $new_settings[$setting_key_map] = $setting_key_map !== 'color' ? $value : $value[0]; + } + // Then we remove it from the main old settings tree. + unset($old_settings[$setting_id]); + } + + // Allow other modules to alter the new settings. + $this->moduleHandler->alter('charts_version3_to_new_settings_structure', $new_settings, $for, $this); + return $new_settings; + } + + /** + * Initialize the current default settings. + */ + public function initializedCurrentDefaultSettings() { + $path = $this->moduleExtensionList->getPath('charts'); + $default_install_settings_file = $path . '/config/install/charts.settings.yml'; + $default_install_settings = Yaml::decode(file_get_contents($default_install_settings_file)); + + $new_settings = &$default_install_settings['charts_default_settings']; + + // Allow other modules to alter the new settings. + $for = 'config'; + $this->moduleHandler->alter('charts_version3_to_new_settings_structure', $new_settings, $for, $this); + return $default_install_settings; + } + + /** + * Updates settings from version 3 of views. + */ + public function updateExistingViewsVersion3ToNewSettings() { + $view_storage = $this->entityTypeManager->getStorage('view'); + $view_ids = $view_storage->getQuery() + ->accessCheck(FALSE) + ->condition('display.*.display_options.style.type', 'chart', '=') + ->execute(); + + if (!$view_ids) { + return 'Views: No views had a display set to a charts style.'; + } + + $updated_views = []; + foreach ($view_ids as $view_id) { + /** @var \Drupal\views\ViewEntityInterface $view */ + if (!($view = $view_storage->load($view_id))) { + continue; + } + + $changed = FALSE; + $displays = $view->get('display'); + foreach ($displays as &$display) { + $style = &$display['display_options']['style']; + if ($style['type'] !== 'chart' || !isset($style['options']['field_colors']) || !isset($style['options']['fields']['table'])) { + continue; + } + + $changed = TRUE; + // Removing this because it was set in version 3 but was not used for + // anything. + unset($style['options']['fields']); + + $options = &$style['options']; + $options = $this->transformVersion3SettingsToNew($options, 'view'); + $chart_settings_elements = [ + 'library', + 'type', + 'fields', + 'display', + 'xaxis', + 'yaxis', + ]; + foreach ($options as $option_key => $option) { + if (in_array($option_key, $chart_settings_elements)) { + $options['chart_settings'][$option_key] = $option; + unset($options[$option_key]); + } + } + } + if ($changed) { + $view->set('display', $displays); + $view->save(); + $updated_views[] = $view_id; + } + } + + if ($updated_views) { + return sprintf('Views: The following views were updated: %s', implode(', ', $updated_views)); + } + return sprintf('Views: The following views(%s) with at least one display of charts style were loaded but not updated!', implode(', ', array_values($view_ids))); + } + + /** + * Transforms boolean string value to real boolean. + * + * @param mixed $value + * The value to be transformed. + * + * @return bool|mixed + * The boolean value or the original passed value. + */ + public function transformBoolStringValueToBool($value) { + if ($value === 'FALSE' || $value === 'false') { + return FALSE; + } + elseif ($value === 'TRUE' || $value === 'true') { + return TRUE; + } + return $value; + } + + /** + * Transforms legacy fields data providers to new. + * + * @param array $data_providers + * Data providers. + * @param array $legacy_value + * Legacy value. + * + * @return mixed + * Data providers returned + */ + private function transformLegacyFieldsDataProvidersToNew(array $data_providers, array $legacy_value) { + $default_weight = 0; + foreach ($legacy_value as $field_id => $value) { + if (Color::validateHex($value)) { + $data_providers[$field_id]['color'] = $value; + } + else { + $data_providers[$field_id]['enabled'] = !empty($value); + } + $data_providers[$field_id]['weight'] = $default_weight; + $default_weight++; + } + return $data_providers; + } + + /** + * Gets legacy settings mapping keys. + * + * @return array + * Legacy settings keys to newer ones mapping. + */ + private function getLegacySettingsMappingKeys() { + return [ + 'library' => 'library', + 'chart_library' => 'library', + 'type' => 'type', + 'chart_type' => 'type', + 'grouping' => 'grouping', + 'title' => 'display_title', + 'title_position' => 'display_title_position', + 'data_labels' => 'display_data_labels', + 'data_markers' => 'display_data_markers', + 'legend' => 'display_legend', + 'legend_position' => 'display_legend_position', + 'background' => 'display_background', + 'three_dimensional' => 'display_three_dimensional', + 'polar' => 'display_polar', + 'series' => 'series', + 'data' => 'data', + 'color' => 'color', + 'data_series' => 'data_series', + 'series_label' => 'series_label', + 'categories' => 'categories', + 'field_colors' => 'fields_data_providers', + 'tooltips' => 'display_tooltips', + 'tooltips_use_html' => 'display_tooltips_use_html', + 'width' => 'display_dimensions_width', + 'height' => 'display_dimensions_height', + 'width_units' => 'display_dimensions_width_units', + 'height_units' => 'display_dimensions_height_units', + 'colors' => 'display_colors', + 'xaxis_title' => 'xaxis_title', + 'xaxis_labels_rotation' => 'xaxis_labels_rotation', + 'yaxis_title' => 'yaxis_title', + 'yaxis_min' => 'yaxis_min', + 'yaxis_max' => 'yaxis_max', + 'yaxis_prefix' => 'yaxis_prefix', + 'yaxis_suffix' => 'yaxis_suffix', + 'yaxis_decimal_count' => 'yaxis_decimal_count', + 'yaxis_labels_rotation' => 'yaxis_labels_rotation', + 'inherit_yaxis' => 'yaxis_inherit', + 'secondary_yaxis_title' => 'yaxis_secondary_title', + 'secondary_yaxis_min' => 'yaxis_secondary_min', + 'secondary_yaxis_max' => 'yaxis_secondary_min', + 'secondary_yaxis_prefix' => 'yaxis_secondary_prefix', + 'secondary_yaxis_suffix' => 'yaxis_secondary_suffix', + 'secondary_yaxis_decimal_count' => 'yaxis_secondary_decimal_count', + 'secondary_yaxis_labels_rotation' => 'yaxis_secondary_labels_rotation', + 'green_from' => 'display_gauge_green_from', + 'green_to' => 'display_gauge_green_to', + 'red_from' => 'display_gauge_red_from', + 'red_to' => 'display_gauge_red_to', + 'yellow_from' => 'display_gauge_yellow_from', + 'yellow_to' => 'display_gauge_yellow_to', + 'max' => 'display_gauge_max', + 'min' => 'display_gauge_min', + 'allow_advanced_rendering' => 'fields_allow_advanced_rendering', + 'label_field' => 'fields_label', + 'data_fields' => 'fields_data_providers', + ]; + } + +} diff --git a/web/modules/charts/src/DependenciesCalculatorTrait.php b/web/modules/charts/src/DependenciesCalculatorTrait.php new file mode 100644 index 00000000..3fad82cb --- /dev/null +++ b/web/modules/charts/src/DependenciesCalculatorTrait.php @@ -0,0 +1,87 @@ +chartPluginManager()->getDefinition($chart_library_id); + $dependent_modules = [$plugin_definition['provider']]; + } + if ($chart_type_id) { + // Do the same thing for the chart type unless it was added by the main + // "charts" module. + $plugin_definition = $this->chartTypePluginManager()->getDefinition($chart_type_id); + $provider = $plugin_definition['provider']; + if ($provider !== 'charts' && !in_array($provider, $dependent_modules)) { + $dependent_modules[] = $provider; + } + } + if ($dependent_modules) { + $calculated_dependencies = ['module' => $dependent_modules]; + } + return $calculated_dependencies; + } + + /** + * Initialize the chart plugin manager if needed. + * + * @return \Drupal\charts\ChartManager + * The chart manager plugin. + */ + private function chartPluginManager(): ChartManager { + if (!isset($this->chartPluginManager)) { + $this->chartPluginManager = \Drupal::service('plugin.manager.charts'); + } + return $this->chartPluginManager; + } + + /** + * Initialize the chart type plugin manager if needed. + * + * @return \Drupal\charts\TypeManager + * The chart type manager plugin. + */ + private function chartTypePluginManager(): TypeManager { + if (!isset($this->chartTypePluginManager)) { + $this->chartTypePluginManager = \Drupal::service('plugin.manager.charts_type'); + } + return $this->chartTypePluginManager; + } + +} diff --git a/web/modules/charts/src/Element/BaseSettings.php b/web/modules/charts/src/Element/BaseSettings.php new file mode 100755 index 00000000..cd18af28 --- /dev/null +++ b/web/modules/charts/src/Element/BaseSettings.php @@ -0,0 +1,1644 @@ + 'charts_settings', + * '#title' => 'Charts configurations', + * '#used_in' => 'basic_form', + * ]; + * @endcode + * + * @FormElement("charts_settings") + */ +class BaseSettings extends FormElementBase { + + use ColorHelperTrait; + use ElementFormStateTrait; + + /** + * {@inheritdoc} + */ + public function getInfo() { + $class = get_class($this); + return [ + '#input' => TRUE, + '#tree' => TRUE, + '#default_value' => [], + '#used_in' => 'basic_form', + '#series' => FALSE, + '#required' => FALSE, + '#field_options' => [], + '#library' => '', + '#process' => [ + [$class, 'attachLibraryElementSubmit'], + [$class, 'processSettings'], + [$class, 'processGroup'], + ], + '#element_validate' => [ + [$class, 'validateLibraryPluginConfiguration'], + ], + '#charts_library_settings_element_submit' => [ + [$class, 'submitLibraryPluginConfiguration'], + ], + '#theme_wrappers' => ['container'], + ]; + } + + /** + * Processes the settings element. + * + * @param array $element + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $complete_form + * The complete form. + * + * @return array + * The element. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public static function processSettings(array &$element, FormStateInterface $form_state, array &$complete_form = []) { + $supported_usage = ['basic_form', 'config_form', 'view_form']; + if (empty($element['#used_in']) || !in_array($element['#used_in'], $supported_usage)) { + throw new \InvalidArgumentException('The chart_base_settings element can only be used in basic, config and view forms.'); + } + if (!is_array($element['#value'])) { + throw new \InvalidArgumentException('The chart_base_settings #default_value must be an array.'); + } + $parents = $element['#parents']; + $id_prefix = implode('-', $parents); + $wrapper_id = Html::getUniqueId($id_prefix . '-ajax-wrapper'); + $value = $element['#value'] ?? []; + + // Collect the main prefix and suffix just in case this element is wrapped + // with one. + $main_prefix = $element['#prefix'] ?? ''; + $main_suffix = $element['#suffix'] ?? ''; + + // Enforce tree. + $element = [ + '#tree' => TRUE, + '#prefix' => $main_prefix . '
', + '#suffix' => '
' . $main_suffix, + // Pass the id along to other methods. + '#wrapper_id' => $wrapper_id, + ] + $element; + $used_in = $element['#used_in'] ?: ''; + + $required = !empty($element['#required']) ? $element['#required'] : FALSE; + $options = $value; + + $library_options = self::getLibraries(); + if ($used_in !== 'config_form' && $library_options) { + $library_options = [ + 'site_default' => new TranslatableMarkup('Site Default'), + ] + $library_options; + } + if (!$library_options) { + $element['no_chart_plugin_error'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['messages', 'messages--error'], + ], + 'content' => [ + '#markup' => new TranslatableMarkup('

Please install at least one module that implements a chart library plugin.

'), + ], + ]; + return $element; + } + + if (!empty($element['#library']) && isset($library_options[$element['#library']])) { + $element['library'] = [ + '#type' => 'value', + '#value' => $element['#library'], + ]; + $selected_library = $element['#library']; + } + else { + $element['library'] = [ + '#title' => new TranslatableMarkup('Charting library'), + '#type' => 'select', + '#options' => $library_options, + '#default_value' => $options['library'], + '#required' => $required, + '#access' => count($library_options) > 0, + '#attributes' => ['class' => ['chart-library-select']], + '#ajax' => [ + 'callback' => [get_called_class(), 'ajaxRefresh'], + 'wrapper' => $wrapper_id, + ], + ]; + $selected_library = $options['library'] ?: array_key_first($library_options); + } + + // Making sure that the selected chart type is part of chart type options + // associated with the library. + $chart_type_options = self::getChartTypes($selected_library); + $selected_chart_type = $options['type'] ?? ''; + if (!isset($chart_type_options[$selected_chart_type])) { + $selected_chart_type = NULL; + } + $element['type'] = [ + '#title' => new TranslatableMarkup('Chart type'), + '#type' => 'radios', + '#default_value' => $selected_chart_type, + '#options' => $chart_type_options, + '#access' => !empty($chart_type_options), + '#required' => $required, + '#attributes' => [ + 'class' => [ + 'chart-type-radios', + 'container-inline', + ], + ], + ]; + + if (!$chart_type_options) { + $error_message = $selected_library !== 'site_default' ? + new TranslatableMarkup('

The selected chart library does not have valid chart type options. Please select another charting library or install a module that have chart type options.

') : + new TranslatableMarkup('

The site default charting library has not been set yet, or it does not support chart type options. Please ensure that you have correctly configured the chart module.

'); + $element['no_chart_type_plugin_error'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['messages', 'messages--error']], + 'content' => ['#markup' => $error_message], + ]; + return $element; + } + + if (!empty($element['#series'])) { + $element = self::processSeriesForm($element, $options, $form_state); + } + + $element['display'] = [ + '#title' => new TranslatableMarkup('Display'), + '#type' => 'fieldset', + '#collapsible' => TRUE, + '#collapsed' => TRUE, + ]; + + $element['display']['title'] = [ + '#title' => new TranslatableMarkup('Chart title'), + '#type' => 'textfield', + '#default_value' => $options['display']['title'], + ]; + + $element['display']['subtitle'] = [ + '#title' => new TranslatableMarkup('Chart subtitle'), + '#type' => 'textfield', + '#default_value' => $options['display']['subtitle'] ?? '', + '#description' => new TranslatableMarkup('Not all charting libraries support this option. Disabled until there is a title value.'), + '#states' => [ + 'disabled' => [ + ':input[name="' . $element['#name'] . '[display][title]"]' => ['value' => ''], + ], + ], + ]; + + $element['xaxis'] = [ + '#title' => new TranslatableMarkup('Horizontal axis'), + '#type' => 'fieldset', + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#attributes' => ['class' => ['chart-xaxis']], + ]; + + $element['yaxis'] = [ + '#title' => new TranslatableMarkup('Vertical axis'), + '#type' => 'fieldset', + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#attributes' => ['class' => ['chart-yaxis']], + ]; + + if ($used_in === 'view_form') { + $element = self::processViewForm($element, $options, $complete_form, $form_state); + } + elseif ($used_in === 'config_form') { + $element = self::processConfigForm($element, $options, $form_state); + } + + $element['display']['title_position'] = [ + '#title' => new TranslatableMarkup('Title position'), + '#type' => 'select', + '#options' => [ + '' => new TranslatableMarkup('None'), + 'out' => new TranslatableMarkup('Outside'), + 'in' => new TranslatableMarkup('Inside'), + 'top' => new TranslatableMarkup('Top'), + 'right' => new TranslatableMarkup('Right'), + 'bottom' => new TranslatableMarkup('Bottom'), + 'left' => new TranslatableMarkup('Left'), + ], + '#description' => new TranslatableMarkup('Not all of these will apply to your selected library.'), + '#default_value' => $options['display']['title_position'] ?? '', + ]; + + $element['display']['tooltips'] = [ + '#title' => new TranslatableMarkup('Enable tooltips'), + '#type' => 'checkbox', + '#description' => new TranslatableMarkup('Show data details on mouse over? Note: unavailable for print or on mobile devices.'), + '#default_value' => !empty($options['display']['tooltips']), + ]; + + $element['display']['data_labels'] = [ + '#title' => new TranslatableMarkup('Enable data labels'), + '#type' => 'checkbox', + '#default_value' => !empty($options['display']['data_labels']), + '#description' => new TranslatableMarkup('Show data details as labels on chart? Note: recommended for print or on mobile devices.'), + ]; + + $element['display']['data_markers'] = [ + '#title' => new TranslatableMarkup('Enable data markers'), + '#type' => 'checkbox', + '#default_value' => !empty($options['display']['data_markers']), + '#description' => new TranslatableMarkup('Show data markers (points) on line charts?'), + ]; + + $element['display']['legend_position'] = [ + '#title' => new TranslatableMarkup('Legend position'), + '#type' => 'select', + '#options' => [ + '' => new TranslatableMarkup('None'), + 'top' => new TranslatableMarkup('Top'), + 'right' => new TranslatableMarkup('Right'), + 'bottom' => new TranslatableMarkup('Bottom'), + 'left' => new TranslatableMarkup('Left'), + ], + '#default_value' => $options['display']['legend_position'] ?? '', + ]; + + $element['display']['background'] = [ + '#title' => new TranslatableMarkup('Background color'), + '#type' => 'textfield', + '#size' => 10, + '#maxlength' => 7, + '#attributes' => ['placeholder' => new TranslatableMarkup('transparent')], + '#description' => new TranslatableMarkup('Leave blank for a transparent background.'), + '#default_value' => $options['display']['background'] ?? '', + ]; + + $element['display']['three_dimensional'] = [ + '#title' => new TranslatableMarkup('Make chart three-dimensional (3D)'), + '#type' => 'checkbox', + '#default_value' => $options['display']['three_dimensional'] ?? FALSE, + '#attributes' => [ + 'class' => [ + 'chart-type-checkbox', + 'container-inline', + ], + ], + ]; + + $element['display']['polar'] = [ + '#title' => new TranslatableMarkup('Transform cartesian charts into the polar coordinate system'), + '#type' => 'checkbox', + '#default_value' => $options['display']['polar'] ?? FALSE, + '#attributes' => [ + 'class' => [ + 'chart-type-checkbox', + 'container-inline', + ], + ], + ]; + + $element['display']['dimensions'] = [ + '#title' => new TranslatableMarkup('Dimensions'), + '#theme_wrappers' => ['form_element'], + '#description' => new TranslatableMarkup('If dimensions are left empty, the chart will fill its containing element.'), + ]; + + $element['display']['dimensions']['width'] = [ + '#type' => 'number', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('auto'), + ], + '#min' => 0, + '#max' => 9999, + '#default_value' => $options['display']['dimensions']['width'] ?? '', + '#size' => 8, + '#theme_wrappers' => [], + ]; + $element['display']['dimensions']['width_units'] = [ + '#type' => 'textfield', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('%'), + ], + '#default_value' => $options['display']['dimensions']['width_units'] ?? '', + '#suffix' => ' x ', + '#size' => 2, + '#theme_wrappers' => [], + ]; + + $element['display']['dimensions']['height'] = [ + '#type' => 'number', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('auto'), + ], + '#min' => 0, + '#max' => 9999, + '#default_value' => $options['display']['dimensions']['height'] ?? '', + '#size' => 8, + '#theme_wrappers' => [], + ]; + $element['display']['dimensions']['height_units'] = [ + '#type' => 'textfield', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('px'), + ], + '#default_value' => $options['display']['dimensions']['height_units'] ?? '', + '#size' => 2, + '#theme_wrappers' => [], + ]; + + $element['xaxis']['title'] = [ + '#title' => new TranslatableMarkup('Custom title'), + '#type' => 'textfield', + '#default_value' => $options['xaxis']['title'] ?? '', + ]; + + $element['xaxis']['labels_rotation'] = [ + '#title' => new TranslatableMarkup('Labels rotation'), + '#type' => 'select', + '#options' => [ + 0 => new TranslatableMarkup('0°'), + 15 => new TranslatableMarkup('15°'), + 30 => new TranslatableMarkup('30°'), + 45 => new TranslatableMarkup('45°'), + 60 => new TranslatableMarkup('60°'), + 90 => new TranslatableMarkup('90°'), + ], + // This is only shown on non-inverted charts. + '#attributes' => ['class' => ['axis-inverted-hide']], + '#default_value' => $options['xaxis']['labels_rotation'] ?? '', + ]; + + $element['yaxis']['title'] = [ + '#title' => new TranslatableMarkup('Custom title'), + '#type' => 'textfield', + '#default_value' => $options['yaxis']['title'] ?? '', + ]; + + $element['yaxis']['min_max_label'] = [ + '#type' => 'html_tag', + '#tag' => 'label', + '#value' => new TranslatableMarkup('Value range'), + ]; + $element['yaxis']['min'] = [ + '#type' => 'number', + '#title' => new TranslatableMarkup('Value range minimum'), + '#title_display' => 'invisible', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('Minimum'), + ], + '#max' => 999999999, + '#default_value' => $options['yaxis']['min'] ?? '', + '#size' => 12, + '#suffix' => ' ', + ]; + $element['yaxis']['max'] = [ + '#type' => 'number', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('Maximum'), + ], + '#max' => 999999999, + '#default_value' => $options['yaxis']['max'] ?? '', + '#size' => 12, + ]; + + $element['yaxis']['prefix'] = [ + '#title' => new TranslatableMarkup('Value prefix'), + '#type' => 'textfield', + '#default_value' => $options['yaxis']['prefix'] ?? '', + '#size' => 12, + ]; + $element['yaxis']['suffix'] = [ + '#title' => new TranslatableMarkup('Value suffix'), + '#type' => 'textfield', + '#default_value' => $options['yaxis']['suffix'] ?? '', + '#size' => 12, + ]; + + $element['yaxis']['decimal_count'] = [ + '#title' => new TranslatableMarkup('Decimal count'), + '#type' => 'number', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('auto'), + ], + '#min' => 0, + '#max' => 20, + '#default_value' => $options['yaxis']['decimal_count'] ?? '', + '#size' => 5, + '#description' => new TranslatableMarkup('Enforce a certain number of decimal-place digits in displayed values.'), + ]; + + $element['yaxis']['labels_rotation'] = [ + '#title' => new TranslatableMarkup('Labels rotation'), + '#type' => 'select', + '#options' => [ + 0 => new TranslatableMarkup('0°'), + 15 => new TranslatableMarkup('15°'), + 30 => new TranslatableMarkup('30°'), + 45 => new TranslatableMarkup('45°'), + 60 => new TranslatableMarkup('60°'), + 90 => new TranslatableMarkup('90°'), + ], + // This is only shown on inverted charts. + '#attributes' => ['class' => ['axis-inverted-show']], + '#default_value' => $options['yaxis']['labels_rotation'] ?? '', + ]; + + // Adding basic form yaxis other fields. + if ($used_in === 'basic_form') { + $element = self::processBasicForm($element, $options); + } + if ($used_in === 'view_form' || $used_in === 'basic_form') { + $element['display']['color_changer'] = [ + '#title' => new TranslatableMarkup('Enable color changer widget'), + '#type' => 'checkbox', + '#description' => new TranslatableMarkup('Display a widget that enables users to switch the chart colors.'), + '#default_value' => !empty($options['display']['color_changer']), + ]; + } + + // Settings for gauges. + $element['display']['gauge'] = [ + '#title' => new TranslatableMarkup('Gauge settings'), + '#type' => 'fieldset', + '#collapsible' => FALSE, + '#states' => [ + 'visible' => [ + ':input[class*=chart-type-radios]' => ['value' => 'gauge'], + ], + ], + 'max' => [ + '#title' => new TranslatableMarkup('Gauge maximum value'), + '#type' => 'number', + '#default_value' => $options['display']['gauge']['max'] ?? '', + ], + 'min' => [ + '#title' => new TranslatableMarkup('Gauge minimum value'), + '#type' => 'number', + '#default_value' => $options['display']['gauge']['min'] ?? '', + ], + 'green_from' => [ + '#title' => new TranslatableMarkup('Green minimum value'), + '#type' => 'number', + '#default_value' => $options['display']['gauge']['green_from'] ?? '', + ], + 'green_to' => [ + '#title' => new TranslatableMarkup('Green maximum value'), + '#type' => 'number', + '#default_value' => $options['display']['gauge']['green_to'] ?? '', + ], + 'yellow_from' => [ + '#title' => new TranslatableMarkup('Yellow minimum value'), + '#type' => 'number', + '#default_value' => $options['display']['gauge']['yellow_from'] ?? '', + ], + 'yellow_to' => [ + '#title' => new TranslatableMarkup('Yellow maximum value'), + '#type' => 'number', + '#default_value' => $options['display']['gauge']['yellow_to'] ?? '', + ], + 'red_from' => [ + '#title' => new TranslatableMarkup('Red minimum value'), + '#type' => 'number', + '#default_value' => $options['display']['gauge']['red_from'] ?? '', + ], + 'red_to' => [ + '#title' => new TranslatableMarkup('Red maximum value'), + '#type' => 'number', + '#default_value' => $options['display']['gauge']['red_to'] ?? '', + ], + ]; + + if ($used_in === 'config_form' && $selected_library) { + $element = self::buildLibraryConfigurationForm($element, $form_state, $selected_library); + } + + return $element; + } + + /** + * Validates the chart library plugin configuration. + * + * @param array $element + * The chart base settings element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $complete_form + * The complete form. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public static function validateLibraryPluginConfiguration(array &$element, FormStateInterface $form_state, array &$complete_form) { + $used_in = $element['#used_in']; + if ($used_in === 'config_form') { + $settings = $form_state->getValue($element['#parents']); + // Adding validate callback for the chart library settings. + if (!empty($settings['library'])) { + /** @var \Drupal\Component\Plugin\PluginManagerInterface $plugin_manager */ + $plugin_manager = \Drupal::service('plugin.manager.charts'); + /** @var \Drupal\charts\Plugin\chart\Library\ChartInterface $plugin */ + $plugin = $plugin_manager->createInstance($settings['library']); + $plugin->validateConfigurationForm($element['library_config'], $form_state); + } + } + } + + /** + * Submits the plugin configuration. + * + * @param array $element + * An associative array containing the properties of the element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public static function submitLibraryPluginConfiguration(array &$element, FormStateInterface $form_state) { + $used_in = $element['#used_in']; + if ($used_in === 'config_form') { + $settings = $form_state->getValue($element['#parents']); + if (!empty($settings['library'])) { + /** @var \Drupal\Component\Plugin\PluginManagerInterface $plugin_manager */ + $plugin_manager = \Drupal::service('plugin.manager.charts'); + /** @var \Drupal\charts\Plugin\chart\Library\ChartInterface $plugin */ + $plugin = $plugin_manager->createInstance($settings['library']); + $plugin->submitConfigurationForm($element['library_config'], $form_state); + $form_state->setValueForElement($element['library_config'], $plugin->getConfiguration()); + } + } + } + + /** + * Ajax callback. + */ + public static function ajaxRefresh(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + return NestedArray::getValue($form, array_slice($triggering_element['#array_parents'], 0, -1)); + } + + /** + * Get the libraries. + * + * @return array + * The library options. + */ + public static function getLibraries() { + // Using plugins to get the available installed libraries. + $plugin_manager = \Drupal::service('plugin.manager.charts'); + $plugin_definitions = $plugin_manager->getDefinitions(); + $library_options = []; + + foreach ($plugin_definitions as $plugin_definition) { + $library_options[$plugin_definition['id']] = $plugin_definition['name']; + } + + return $library_options; + } + + /** + * Gets chart types by chart library. + * + * @param string $library_plugin_id + * The library. + * + * @return array + * The type options. + */ + public static function getChartTypes(string $library_plugin_id = ''): array { + if ($library_plugin_id === 'site_default') { + $library_plugin_id = static::getConfiguredSiteDefaultLibraryId(); + } + if (!$library_plugin_id) { + \Drupal::logger('charts')->error('Update your custom code to pass the library in getChartTypes() or install a module that implement a charting library plugin.'); + return []; + } + + $chart_type_plugin_manager = \Drupal::service('plugin.manager.charts_type'); + $chart_plugin_manager = \Drupal::service('plugin.manager.charts'); + + /** @var \Drupal\charts\Plugin\chart\Library\ChartInterface $chart_plugin */ + $chart_plugin = $chart_plugin_manager->createInstance($library_plugin_id, []); + $chart_type_plugin_definitions = $chart_type_plugin_manager->getDefinitions(); + $types_options = []; + + foreach ($chart_type_plugin_definitions as $plugin_definition) { + $chart_type_id = $plugin_definition['id']; + if ($chart_plugin->isSupportedChartType($chart_type_id)) { + $types_options[$chart_type_id] = $plugin_definition['label']; + } + } + return $types_options; + } + + /** + * Gets the configured site default library chart plugin id. + * + * @return string + * The plugin id or an empty string if nothing has been configured. + */ + public static function getConfiguredSiteDefaultLibraryId(): string { + $chart_config = \Drupal::config('charts.settings'); + return $chart_config->get('charts_default_settings.library') ?? ''; + } + + /** + * Attaches the #charts_library_settings_element_submit functionality. + * + * @param array $element + * The form element being processed. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array $complete_form + * The complete form structure. + * + * @return array + * The processed form element. + */ + public static function attachLibraryElementSubmit(array $element, FormStateInterface $form_state, array &$complete_form) { + if (isset($complete_form['#charts_library_settings_element_submit_attached'])) { + return $element; + } + // The #validate callbacks of the complete form run last. + // That allows executeElementSubmitHandlers() to be completely certain that + // the form has passed validation before proceeding. + $complete_form['#validate'][] = [ + static::class, + 'executeLibraryElementSubmitHandlers', + ]; + $complete_form['#charts_library_settings_element_submit_attached'] = TRUE; + + return $element; + } + + /** + * Submits elements by calling their #charts_library_settings_element_submit. + * + * Callbacks. + * + * This approach was took from the commerce module to work around the fact. + * that drupal core doesn't have an element_submit property. + * + * @param array &$form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public static function executeLibraryElementSubmitHandlers(array &$form, FormStateInterface $form_state) { + if (!$form_state->isSubmitted() || $form_state->hasAnyErrors()) { + // The form wasn't submitted (#ajax in progress) or failed validation. + return; + } + $triggering_element = $form_state->getTriggeringElement(); + $button_type = $triggering_element['#button_type'] ?? ''; + if ($button_type != 'primary' && count($form_state->getButtons()) > 1) { + // The form was submitted, but not via the primary button, which + // indicates that it will probably be rebuilt. + return; + } + + self::doExecuteLibrarySubmitHandlers($form, $form_state); + } + + /** + * Calls the #charts_library_settings_element_submit callbacks recursively. + * + * @param array &$element + * The current element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public static function doExecuteLibrarySubmitHandlers(array &$element, FormStateInterface $form_state) { + // Recurse through all children. + foreach (Element::children($element) as $key) { + if (!empty($element[$key])) { + static::executeLibraryElementSubmitHandlers($element[$key], $form_state); + } + } + + // If there are callbacks on this level, run them. + if (!empty($element['#charts_library_settings_element_submit'])) { + foreach ($element['#charts_library_settings_element_submit'] as $callback) { + call_user_func_array($callback, [&$element, &$form_state]); + } + } + } + + /** + * Process form. + * + * @param array $element + * The current element. + * @param array $options + * Options. + * + * @return array + * The element. + */ + private static function processBasicForm(array $element, array $options) { + $element_name = $element['#name']; + + $element['display']['stacking'] = [ + '#title' => new TranslatableMarkup('Enable stacking'), + '#type' => 'checkbox', + '#description' => new TranslatableMarkup('Enable stacking for this chart. Will stack based on the selected label field.'), + '#default_value' => !empty($options['display']['stacking']), + ]; + + $element['yaxis']['inherit'] = [ + '#title' => new TranslatableMarkup('Add a secondary y-axis'), + '#type' => 'checkbox', + '#default_value' => $options['yaxis']['inherit'] ?? FALSE, + '#description' => new TranslatableMarkup('Only one additional (secondary) y-axis can be created.'), + ]; + + $element['yaxis']['secondary'] = [ + '#title' => new TranslatableMarkup('Secondary vertical axis'), + '#type' => 'fieldset', + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#attributes' => ['class' => ['chart-yaxis']], + '#states' => [ + 'visible' => [ + ':input[name="' . $element_name . '[yaxis][inherit]"]' => ['checked' => TRUE], + ], + ], + ]; + + $element['yaxis']['secondary']['title'] = [ + '#title' => new TranslatableMarkup('Custom title'), + '#type' => 'textfield', + '#default_value' => $options['yaxis']['secondary']['title'] ?? '', + ]; + + $element['yaxis']['secondary']['min_max_label'] = [ + '#type' => 'html_tag', + '#tag' => 'label', + '#value' => new TranslatableMarkup('Value range'), + ]; + $element['yaxis']['secondary']['min'] = [ + '#type' => 'number', + '#title' => new TranslatableMarkup('Value range minimum'), + '#title_display' => 'invisible', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('Minimum'), + ], + '#max' => 999999999, + '#size' => 12, + '#suffix' => ' ', + '#default_value' => $options['yaxis']['secondary']['min'] ?? '', + ]; + $element['yaxis']['secondary']['max'] = [ + '#type' => 'number', + '#title' => new TranslatableMarkup('Value range maximum'), + '#title_display' => 'invisible', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('Maximum'), + ], + '#max' => 999999999, + '#size' => 12, + '#default_value' => $options['yaxis']['secondary']['max'] ?? '', + ]; + + $element['yaxis']['secondary']['prefix'] = [ + '#title' => new TranslatableMarkup('Value prefix'), + '#type' => 'textfield', + '#size' => 12, + '#default_value' => $options['yaxis']['secondary']['prefix'] ?? '', + ]; + + $element['yaxis']['secondary']['suffix'] = [ + '#title' => new TranslatableMarkup('Value suffix'), + '#type' => 'textfield', + '#size' => 12, + '#default_value' => $options['yaxis']['secondary']['suffix'] ?? '', + ]; + + $element['yaxis']['secondary']['decimal_count'] = [ + '#title' => new TranslatableMarkup('Decimal count'), + '#type' => 'number', + '#attributes' => [ + 'placeholder' => new TranslatableMarkup('auto'), + ], + '#max' => 20, + '#min' => 0, + '#size' => 5, + '#description' => new TranslatableMarkup('Enforce a certain number of decimal-place digits in displayed values.'), + '#default_value' => $options['yaxis']['secondary']['decimal_count'] ?? '', + ]; + + $element['yaxis']['secondary']['labels_rotation'] = [ + '#title' => new TranslatableMarkup('Labels rotation'), + '#type' => 'select', + '#options' => [ + 0 => new TranslatableMarkup('0°'), + 15 => new TranslatableMarkup('15°'), + 30 => new TranslatableMarkup('30°'), + 45 => new TranslatableMarkup('45°'), + 60 => new TranslatableMarkup('60°'), + 90 => new TranslatableMarkup('90°'), + ], + // This is only shown on inverted charts. + '#attributes' => ['class' => ['axis-inverted-show']], + '#default_value' => $options['yaxis']['secondary']['labels_rotation'] ?? '', + ]; + + return $element; + } + + /** + * Process view form. + * + * @param array $element + * The current element. + * @param array $options + * The options. + * @param array $complete_form + * The complete form where the element is attached to. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The element. + */ + private static function processViewForm(array $element, array $options, array &$complete_form, FormStateInterface $form_state) { + if (!is_array($element['#field_options'])) { + throw new \InvalidArgumentException('The chart_base_settings element need valid field options when used as view form.'); + } + + $element['display']['#weight'] = 2; + $element['xaxis']['#weight'] = 2; + $element['yaxis']['#weight'] = 2; + + $element_name = $element['#name']; + $field_options = $element['#field_options']; + $first_field = $field_options ? key($field_options) : ''; + + $element['fields'] = [ + '#title' => new TranslatableMarkup('Charts fields'), + '#type' => 'fieldset', + '#weight' => 1, + ]; + + // Add a views-specific chart option to allow advanced rendering. + // $element['fields']['allow_advanced_rendering'] = [ + // '#type' => 'checkbox', + // '#title' => new TranslatableMarkup('Allow advanced rendering'), + // '#description' => new TranslatableMarkup('Allow views field rewriting. + // etc. for label and data fields. This can break charts if you rewrite + // the field to a value the charting library cannot handle + // - e.g. passing a string value into a numeric data column.'), + // '#default_value' => isset($options['fields']. + // ['allow_advanced_rendering']) ? $options['fields'] + // ['allow_advanced_rendering'] : NULL,]. + $element['fields']['label'] = [ + '#type' => 'radios', + '#title' => new TranslatableMarkup('Label field'), + '#options' => $field_options + ['' => new TranslatableMarkup('No label field')], + '#default_value' => $options['fields']['label'] ?? $first_field, + ]; + + // Enable stacking. + $element['fields']['stacking'] = [ + '#type' => 'checkbox', + '#title' => new TranslatableMarkup('Stacking'), + '#description' => new TranslatableMarkup('Enable stacking for this chart. Will stack based on the selected label field.'), + '#default_value' => !empty($options['fields']['stacking']) ? $options['fields']['stacking'] : FALSE, + ]; + + $element['fields']['data_providers'] = [ + '#type' => 'table', + '#header' => [ + new TranslatableMarkup('Field Name'), + new TranslatableMarkup('Provides Data'), + new TranslatableMarkup('Color'), + new TranslatableMarkup('Weight'), + ], + '#tabledrag' => [ + [ + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => 'view-chart-fields-data-providers-order-weight', + ], + ], + ]; + // Make the weight list always reflect the current number of values. + // Taken from WidgetBase::formMultipleElements(). + $max_weight = count($field_options); + + $field_options_count = 0; + $default_colors = $options['display']['colors'] ?? []; + foreach ($field_options as $field_name => $field_label) { + $field_option_element = &$element['fields']['data_providers'][$field_name]; + $default_value = $options['fields']['data_providers'][$field_name] ?? []; + $default_weight = $default_value['weight'] ?? $max_weight; + $default_color = $default_value['color'] ?? ''; + if (!$default_color) { + $default_color = $default_colors[$field_options_count] ?? '#000000'; + } + + $field_option_element['#attributes']['class'][] = 'draggable'; + // Field option label. + $field_option_element['label'] = [ + '#markup' => new TranslatableMarkup('@label', [ + '@label' => $field_label, + ]), + ]; + + $field_option_element['enabled'] = [ + '#type' => 'checkbox', + '#title' => new TranslatableMarkup('Provides data'), + '#title_display' => 'invisible', + '#default_value' => !empty($default_value['enabled']), + '#states' => [ + 'disabled' => [ + ':input[name="' . $element_name . '[fields][label]"]' => ['value' => $field_name], + ], + ], + ]; + + $field_option_element['color'] = [ + '#type' => 'textfield', + '#title' => new TranslatableMarkup('Color'), + '#attributes' => [ + 'TYPE' => 'color', + 'style' => 'min-width:50px;', + ], + '#title_display' => 'invisible', + '#size' => 10, + '#maxlength' => 7, + '#default_value' => $default_color, + ]; + + $field_option_element['weight'] = [ + '#type' => 'weight', + '#title' => new TranslatableMarkup('Weight'), + '#title_display' => 'invisible', + '#delta' => $max_weight, + '#default_value' => $default_weight, + '#attributes' => [ + 'class' => ['view-chart-fields-data-providers-order-weight'], + ], + ]; + + $field_option_element['#weight'] = $default_weight; + $field_options_count++; + } + + $element['fields']['entity_grouping'] = [ + '#title' => new TranslatableMarkup('Entity grouping settings'), + '#type' => 'fieldset', + '#collapsed' => TRUE, + '#collapsible' => TRUE, + '#weight' => 2, + '#description' => new TranslatableMarkup('When grouping by an entity reference field, you can set the colors by entities or by a color field attached to the entity type bundle in question.', [ + '@href' => 'https://drupal.org/project/color_field', + ]), + '#description_display' => 'before', + ]; + $module_handler = \Drupal::moduleHandler(); + if (empty($element['#view_charts_style_plugin'])) { + return $element; + } + + // Entity grouping settings. + // Try to get it from $form_state. + $style_options_values = $form_state->getValue(['style_options'], []); + $grouping_field = $style_options_values['grouping'][0] ?? []; + $grouping_field_name = $grouping_field['field'] ?? ''; + $grouping_field_element = $complete_form['options']['style_options']['grouping'][0]['field'] ?? []; + $triggering_element = $form_state->getTriggeringElement(); + if (!$grouping_field_name && !$triggering_element && $grouping_field_element) { + // Get the grouping field name from default value property. + $grouping_field_name = $grouping_field_element['#default_value'] ?? ''; + } + if (!$grouping_field_name) { + return $element; + } + + // Check which selection method the user want to go with. + /** @var \Drupal\charts\Plugin\views\style\ChartsPluginStyleChart $style_plugin */ + $style_plugin = $element['#view_charts_style_plugin']; + $view = $style_plugin->view; + $selection_method_wrapper_id = $view->id() . '--' . $view->current_display . '--' . $style_plugin->getPluginId() . '--fields--entity-grouping--color-selection-method'; + $selected_method = $options['fields']['entity_grouping']['color_selection_method'] ?? 'by_entities_on_entity_reference'; + $element['fields']['entity_grouping']['color_selection_method'] = [ + '#type' => 'radios', + '#title' => new TranslatableMarkup('Color selection method'), + '#required' => TRUE, + '#options' => [ + 'by_entities_on_entity_reference' => new TranslatableMarkup('Set color by entities on entity reference'), + ], + '#default_value' => $selected_method, + '#ajax' => [ + 'wrapper' => $selection_method_wrapper_id, + 'callback' => [ + get_called_class(), + 'groupingChartSettingsSelectedMethodAjaxCallback', + ], + ], + '#limit_validation_errors' => [], + ]; + if ($module_handler->moduleExists('color_field')) { + $element['fields']['entity_grouping']['color_selection_method']['#options']['by_field_on_referenced_entity'] = new TranslatableMarkup('Set color based on a color field on entity reference'); + } + $element['fields']['entity_grouping']['selected_method'] = [ + '#type' => 'container', + '#prefix' => '
', + '#suffix' => '
', + 'colors' => [ + // Empty placeholder. + '#markup' => '', + ], + 'color_field_name' => [ + // Empty placeholder. + '#markup' => '', + ], + ]; + + $fields = $style_plugin->displayHandler->getOption('fields'); + $grouping_field_info = $fields[$grouping_field_name]; + // Get the entity type id of the reference field. + if (!($entity_type_id = static::getReferenceEntityTypeId($grouping_field_info, $selected_method)) || empty($grouping_field_info['field'])) { + return $element; + } + + $metadata = [ + 'grouping_field_name' => $grouping_field_info['field'], + 'selected_method' => $selected_method, + 'entity_type_id' => $entity_type_id, + ]; + $entity_type_manager = \Drupal::entityTypeManager(); + switch ($selected_method) { + case 'by_entities_on_entity_reference': + $metadata['colors'] = $options['fields']['entity_grouping']['selected_method']['colors'] ?? []; + $element['fields']['entity_grouping']['selected_method']['colors'] = static::buildColorsSelectionSubFormByEntities($metadata, $entity_type_manager); + break; + + case 'by_field_on_referenced_entity': + $metadata['color_field_name'] = $options['fields']['entity_grouping']['selected_method']['color_field_name'] ?? ''; + $element['fields']['entity_grouping']['selected_method']['color_field_name'] = static::buildColorsSelectionSubFormByFieldOnReferencedEntity($metadata, $entity_type_manager); + break; + } + + return $element; + } + + /** + * Process config form. + * + * @param array $element + * The current element. + * @param array $options + * Options. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The element. + */ + private static function processConfigForm(array $element, array $options, FormStateInterface $form_state): array { + $parents = $element['#parents']; + $tab_group = implode('][', array_merge($parents, ['defaults'])); + $display_parents = array_merge($parents, ['display']); + $element['defaults'] = [ + '#type' => 'vertical_tabs', + '#default_tab' => 'edit-' . implode('-', $display_parents), + ]; + $element['display']['#type'] = 'details'; + $element['display']['#weight'] = 1; + $element['display']['#group'] = $tab_group; + + $element['xaxis']['#type'] = 'details'; + $element['xaxis']['#weight'] = 2; + $element['xaxis']['#group'] = $tab_group; + + $element['yaxis']['#type'] = 'details'; + $element['yaxis']['#weight'] = 3; + $element['yaxis']['#group'] = $tab_group; + + $id_prefix = implode('-', $parents); + $element_state = static::getElementState($parents, $form_state); + $state_color_indexes_key = $id_prefix . '__default_display_color_indexes'; + $options_display_colors = $options['display']['colors']; + if (!$element_state) { + $element_state = $options; + $options_display_colors_indexes = !empty($options_display_colors) ? array_keys($options_display_colors) : []; + $element_state[$state_color_indexes_key] = $options_display_colors_indexes; + static::setElementState($parents, $form_state, $element_state); + } + else { + $options_display_colors_indexes = $element_state[$state_color_indexes_key]; + } + + $wrapper_id = $id_prefix . '--display-colors'; + $element['display']['colors'] = [ + '#type' => 'table', + '#header' => [ + new TranslatableMarkup('Colors'), + new TranslatableMarkup('Operations'), + ], + '#prefix' => '
', + '#suffix' => '
', + ]; + + // Using the default colors in the settings to populate the colors. + foreach ($options_display_colors_indexes as $color_index => $position) { + $element['display']['colors'][$color_index]['color'] = [ + '#type' => 'textfield', + '#title' => new TranslatableMarkup('Color'), + '#title_display' => 'invisible', + '#attributes' => [ + 'TYPE' => 'color', + 'style' => 'min-width:50px;', + ], + '#size' => 10, + '#maxlength' => 7, + '#theme_wrappers' => [], + ]; + if ($position !== '_new') { + $element['display']['colors'][$color_index]['color']['#default_value'] = $options_display_colors[$color_index]; + } + else { + // Generating a random color for the new default color. + $element['display']['colors'][$color_index]['color']['#default_value'] = static::randomColor(); + } + $element['display']['colors'][$color_index]['remove'] = [ + '#type' => 'submit', + '#name' => 'remove_value_display_colors_color' . $color_index, + '#value' => new TranslatableMarkup('Remove'), + '#limit_validation_errors' => [], + '#submit' => [ + [get_called_class(), 'removeDefaultDisplayColorItemSubmit'], + ], + '#color_index' => $color_index, + '#ajax' => [ + 'callback' => [get_called_class(), 'defaultDisplayColorItemsAjax'], + 'wrapper' => $wrapper_id, + ], + '#operation' => 'remove', + '#state_color_indexes_key' => $state_color_indexes_key, + ]; + } + + $element['display']['colors']['_add_new'] = [ + '#tree' => FALSE, + ]; + $element['display']['colors']['_add_new']['add_item'] = [ + '#type' => 'container', + '#wrapper_attributes' => ['colspan' => 2], + '#tree' => FALSE, + ]; + $element['display']['colors']['_add_new']['add_item']['submit'] = [ + '#type' => 'submit', + '#value' => new TranslatableMarkup('Add a new default color'), + '#submit' => [[get_called_class(), 'addDefaultDisplayColorItemSubmit']], + '#limit_validation_errors' => [], + '#ajax' => [ + 'callback' => [get_called_class(), 'defaultDisplayColorItemsAjax'], + 'wrapper' => $wrapper_id, + ], + '#operation' => 'add', + '#state_color_indexes_key' => $state_color_indexes_key, + ]; + + $element['display']['color_changer'] = [ + '#title' => new TranslatableMarkup('Expose color changer'), + '#type' => 'checkbox', + '#description' => new TranslatableMarkup('Display a widget that enables users to switch the chart colors.'), + '#default_value' => !empty($options['display']['color_changer']), + '#weight' => 10, + ]; + + return $element; + } + + /** + * Process series form. + * + * @param array $element + * The current element. + * @param array $options + * The options. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The element. + * + * @throws \Exception + */ + private static function processSeriesForm(array $element, array $options, FormStateInterface $form_state) { + // Chart preview. + $parents = $element['#parents']; + $css_id_prefix = implode('-', $parents); + $id_prefix = str_replace('-', '_', $css_id_prefix); + $open_preview_elt_state_key = $id_prefix . '__open_preview'; + $element_state = ChartDataCollectorTable::getElementState($parents, $form_state); + + if (!$element_state) { + $element_state = $options; + // Closing preview here cause this is probably initial form load. + $open_preview = FALSE; + $element_state[$open_preview_elt_state_key] = $open_preview; + ChartDataCollectorTable::setElementState($parents, $form_state, $element_state); + } + else { + $open_preview = $element_state[$open_preview_elt_state_key]; + } + + $wrapper_id = $css_id_prefix . '--preview--ajax-wrapper'; + $element['preview'] = [ + '#type' => 'details', + '#title' => new TranslatableMarkup('Preview'), + '#weight' => -99, + '#open' => $open_preview, + '#prefix' => '
', + '#suffix' => '
', + ]; + $element['preview']['submit'] = [ + '#type' => 'submit', + '#value' => new TranslatableMarkup('Update Preview'), + '#name' => $id_prefix . '__preview_submit', + '#attributes' => [ + 'class' => [$css_id_prefix . '--preview-submit'], + ], + '#submit' => [[get_called_class(), 'chartPreviewSubmit']], + '#limit_validation_errors' => [$parents], + '#ajax' => [ + 'callback' => [get_called_class(), 'ajaxRefreshPreview'], + 'progress' => ['type' => 'throbber'], + 'wrapper' => $wrapper_id, + 'effect' => 'fade', + ], + '#operation' => 'preview', + '#open_preview_elt_state_key' => $open_preview_elt_state_key, + ]; + + if (!empty($element_state['library']) && !empty($element_state['series'])) { + $chart_id = $id_prefix . '__preview_chart'; + $chart_build = Chart::buildElement($options, $chart_id); + $chart_build['#id'] = $id_prefix . '--preview-chart'; + // @todo check if this would work with various hooks. + $chart_build['#chart_id'] = $chart_id; + $chart_build['#in_preview_mode'] = TRUE; + $element['preview']['content'] = $chart_build; + } + else { + $element['preview']['content'] = [ + '#markup' => new TranslatableMarkup('

Please fill up the required value below then update the preview.

'), + ]; + } + + if (!empty($element['#default_value'])) { + $options = NestedArray::mergeDeep($options, $element['#default_value']); + } + $element['series'] = [ + '#type' => 'chart_data_collector_table', + '#initial_rows' => $element['#table_initial_rows'] ?? 5, + '#initial_columns' => $element['#table_initial_columns'] ?? 2, + '#table_drag' => FALSE, + '#default_value' => $options['series'] ?? [], + '#default_colors' => $options['display']['colors'] ?? [], + ]; + + return $element; + } + + /** + * Preview refresh Ajax callback. + */ + public static function ajaxRefreshPreview(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $element = NestedArray::getValue($form, array_slice($triggering_element['#array_parents'], 0, -2)); + return $element['preview']; + } + + /** + * Submit callback for the preview button. + */ + public static function chartPreviewSubmit(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $element_parents = array_slice($triggering_element['#parents'], 0, -2); + + // Getting the current element state. + $element_state = ChartDataCollectorTable::getElementState($element_parents, $form_state); + $element_state[$triggering_element['#open_preview_elt_state_key']] = TRUE; + // Updating form state storage. + ChartDataCollectorTable::setElementState($element_parents, $form_state, $element_state); + $form_state->setRebuild(); + } + + /** + * Grouping chart settings ajax callback. + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The render array of the chart settings. + */ + public static function groupingChartSettingsSelectedMethodAjaxCallback(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $entity_grouping_element = NestedArray::getValue($form, array_slice($triggering_element['#array_parents'], 0, -2)); + return $entity_grouping_element['selected_method']; + } + + /** + * Submit callback for adding a new default display color item value. + */ + public static function addDefaultDisplayColorItemSubmit(array $form, FormStateInterface $form_state): void { + $triggering_element = $form_state->getTriggeringElement(); + $element_parents = array_slice($triggering_element['#array_parents'], 0, -5); + $element_state = static::getElementState($element_parents, $form_state); + + // Adding a new default color color item index. + $element_state[$triggering_element['#state_color_indexes_key']][] = '_new'; + // Updating form state storage. + static::setElementState($element_parents, $form_state, $element_state); + $form_state->setRebuild(); + } + + /** + * Submit callback for removing a value. + */ + public static function removeDefaultDisplayColorItemSubmit(array $form, FormStateInterface $form_state): void { + $triggering_element = $form_state->getTriggeringElement(); + $element_parents = array_slice($triggering_element['#array_parents'], 0, -4); + $element_state = static::getElementState($element_parents, $form_state); + $color_index = $triggering_element['#color_index']; + + // Removing the color item. + unset($element_state[$triggering_element['#state_color_indexes_key']][$color_index]); + // Updating form state storage. + static::setElementState($element_parents, $form_state, $element_state); + $form_state->setRebuild(); + } + + /** + * Ajax callback for then default display colors operations. + */ + public static function defaultDisplayColorItemsAjax(array $form, FormStateInterface $form_state): array { + $triggering_element = $form_state->getTriggeringElement(); + $slice_length = $triggering_element['#operation'] === 'add' ? -4 : -3; + $element = NestedArray::getValue($form, array_slice($triggering_element['#array_parents'], 0, $slice_length)); + return $element['colors']; + } + + /** + * Builds the chart library configuration form into the settings. + * + * @param array $element + * The element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param string $library + * The chart library. + * + * @return array + * The configuration subform. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + private static function buildLibraryConfigurationForm(array $element, FormStateInterface $form_state, string $library) { + $plugin_configuration = $element['#value']['library_config'] ?? []; + // Using plugins to get the available installed libraries. + /** @var \Drupal\charts\ChartManager $plugin_manager */ + $plugin_manager = \Drupal::service('plugin.manager.charts'); + /** @var \Drupal\charts\Plugin\chart\Library\ChartInterface $plugin */ + $plugin = $plugin_manager->createInstance($library, $plugin_configuration); + $element['library_config'] = [ + '#type' => 'details', + '#title' => new TranslatableMarkup('@library settings', [ + '@library' => $plugin->getPluginDefinition()['name'], + ]), + '#group' => $element['display']['#group'], + '#weight' => 4, + ]; + $element['library_config'] = $plugin->buildConfigurationForm($element['library_config'], $form_state); + return $element; + } + + /** + * Helper method to retrieve the referenced entity type id. + * + * @param array $field_info + * The field info. + * @param string $selection_method + * The selection method. + * + * @return string + * The entity type id. + */ + private static function getReferenceEntityTypeId(array $field_info, string $selection_method): string { + if (!$selection_method || empty($field_info['type']) || $field_info['type'] !== 'entity_reference_label' || empty($field_info['field'])) { + return ''; + } + $table = Views::viewsData()->get($field_info['table']); + $field_machine_name = $field_info['field']; + return $table[$field_machine_name]['relationship']['entity type'] ?? ''; + } + + /** + * Helper method to build a color selection element by entities. + * + * @param array $metadata + * The metadata information to use to retrieve the color options. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * + * @return array + * The color selection table or a markup message when the provided metadata + * are incomplete. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + private static function buildColorsSelectionSubFormByEntities(array $metadata, EntityTypeManagerInterface $entity_type_manager): array { + $empty_entity_colors = new TranslatableMarkup("No grouping by an entity reference field was detected or the selected field didnt have any entity or color field attached."); + $colors = [ + '#markup' => '

' . $empty_entity_colors . '

', + ]; + if (empty($metadata['grouping_field_name']) || empty($metadata['entity_type_id'])) { + return $colors; + } + + // Identifying the vocabulary this field could belong to. + $field_config_storage = $entity_type_manager->getStorage('field_config'); + /** @var \Drupal\field\FieldConfigInterface[] $grouping_field_configs */ + $grouping_field_configs = $field_config_storage->loadByProperties(['field_name' => $metadata['grouping_field_name']]); + $entity_type_id = $metadata['entity_type_id']; + $bundle_ids = []; + foreach ($grouping_field_configs as $grouping_field_config) { + $field_settings = $grouping_field_config->getSettings(); + if (empty($field_settings['target_type']) || $field_settings['target_type'] !== $entity_type_id) { + continue; + } + $target_bundles = $field_settings['handler_settings']['target_bundles'] ?? []; + foreach ($target_bundles as $bundle_id) { + $bundle_ids[$bundle_id] = $bundle_id; + } + } + + // Load the entities by bundle. + $entity_storage = $entity_type_manager->getStorage($entity_type_id); + $entity_type = $entity_type_manager->getDefinition($entity_type_id); + + $colors = [ + '#type' => 'table', + '#empty' => $empty_entity_colors, + '#header' => [ + new TranslatableMarkup('Entity label'), + new TranslatableMarkup('Bundle'), + new TranslatableMarkup('Color'), + ], + ]; + $bundle_key = $entity_type->getKey('bundle'); + $query = $entity_storage->getQuery()->accessCheck(FALSE) + ->condition($bundle_key, $bundle_ids, 'IN'); + + if ($entity_type instanceof EntityTypeInterface) { + $published_key = $entity_type->getKey('published'); + $query->condition($published_key, TRUE); + } + + // For now limiting to 150 entities. + $entity_ids = $query->range(0, 150) + ->execute(); + $has_uuid_key = $entity_type->hasKey('uuid'); + foreach ($entity_ids as $id) { + $entity = $entity_storage->load($id); + $color_id_key = $has_uuid_key ? $entity->get('uuid')->value : $entity->id(); + $field_option_element = &$colors[$color_id_key]; + $default_value = $metadata['colors'][$color_id_key]['color'] ?? '#000000'; + + $label = $entity->label(); + $field_option_element['label'] = [ + '#markup' => new TranslatableMarkup('@label', [ + '@label' => $label, + ]), + ]; + $field_option_element['bundle'] = [ + '#markup' => new TranslatableMarkup('@bundle', [ + '@bundle' => $entity->bundle(), + ]), + ]; + $field_option_element['color'] = [ + '#type' => 'textfield', + '#title' => new TranslatableMarkup('@label', ['@label' => $label]), + '#title_display' => 'invisible', + '#attributes' => [ + 'TYPE' => 'color', + 'style' => 'min-width:50px;', + ], + '#size' => 10, + '#maxlength' => 7, + '#default_value' => $default_value, + ]; + } + + return $colors; + } + + /** + * Method to build a color selection element by field on referenced entity. + * + * @param array $metadata + * The metadata information to use to retrieve the color options. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * + * @return array + * The select element or the status message when no color options was + * found. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + private static function buildColorsSelectionSubFormByFieldOnReferencedEntity(array $metadata, EntityTypeManagerInterface $entity_type_manager): array { + // Identifying the vocabulary this field could belong to. + $field_config_storage = $entity_type_manager->getStorage('field_config'); + /** @var \Drupal\field\FieldConfigInterface[] $grouping_field_configs */ + $grouping_field_configs = $field_config_storage->loadByProperties(['field_name' => $metadata['grouping_field_name']]); + $entity_type_id = $metadata['entity_type_id']; + $processed_bundle_ids = []; + /** @var \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager */ + $entity_field_manager = \Drupal::service('entity_field.manager'); + $color_field_options = []; + foreach ($grouping_field_configs as $grouping_field_config) { + $field_settings = $grouping_field_config->getSettings(); + if (empty($field_settings['target_type']) || $field_settings['target_type'] !== $entity_type_id) { + continue; + } + + $target_bundles = $field_settings['handler_settings']['target_bundles'] ?? []; + foreach ($target_bundles as $bundle_id) { + if (in_array($bundle_id, $processed_bundle_ids)) { + continue; + } + + // Extract fields from bundle fields. + foreach ($entity_field_manager->getFieldDefinitions($entity_type_id, $bundle_id) as $field_name => $field_definition) { + if ($field_definition->getType() !== 'color_field_type' || !empty($color_field_options[$field_name])) { + continue; + } + $color_field_options[$field_name] = $field_definition->getLabel(); + } + $processed_bundle_ids[] = $bundle_id; + } + } + + if (!$color_field_options) { + $empty_entity_field_name = new TranslatableMarkup("You can't set the color using the selected method because the referenced entity doesn't have any color field type configured."); + return [ + '#theme' => 'status_messages', + '#message_list' => ['warning' => [$empty_entity_field_name]], + '#status_headings' => [ + 'warning' => new TranslatableMarkup('Warning message'), + ], + ]; + } + + return [ + '#type' => 'select', + '#title' => new TranslatableMarkup('Color field'), + '#description' => new TranslatableMarkup('The color field on which we should get the color data from.'), + '#options' => $color_field_options, + '#default_value' => $metadata['color_field_name'] ?? '', + '#required' => TRUE, + ]; + } + +} diff --git a/web/modules/charts/src/Element/Chart.php b/web/modules/charts/src/Element/Chart.php new file mode 100755 index 00000000..36f86a81 --- /dev/null +++ b/web/modules/charts/src/Element/Chart.php @@ -0,0 +1,449 @@ +configFactory = $config_factory; + $this->chartsManager = $chart_manager; + $this->chartsTypeManager = $type_manager; + $this->moduleHandler = $module_handler; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('config.factory'), + $container->get('plugin.manager.charts'), + $container->get('plugin.manager.charts_type'), + $container->get('module_handler') + ); + } + + /** + * {@inheritdoc} + */ + public function getInfo() { + return [ + '#chart_type' => NULL, + '#chart_library' => NULL, + '#chart_id' => NULL, + '#title' => NULL, + '#title_color' => '#000', + '#title_font_weight' => 'normal', + '#title_font_style' => 'normal', + '#title_font_size' => 14, + '#title_position' => 'out', + '#subtitle' => NULL, + '#colors' => ChartBase::getDefaultColors(), + '#font' => 'Arial', + '#font_size' => 12, + '#gauge' => [], + '#background' => 'transparent', + '#stacking' => NULL, + '#color_changer' => FALSE, + '#pre_render' => [ + [$this, 'preRender'], + ], + '#tooltips' => TRUE, + '#tooltips_use_html' => FALSE, + '#data_labels' => FALSE, + '#data_markers' => FALSE, + '#legend' => TRUE, + '#legend_title' => '', + '#legend_title_font_weight' => 'bold', + '#legend_title_font_style' => 'normal', + '#legend_title_font_size' => '', + '#legend_position' => 'right', + '#legend_font_weight' => 'normal', + '#legend_font_style' => 'normal', + '#legend_font_size' => NULL, + '#width' => NULL, + '#height' => NULL, + '#attributes' => [], + '#chart_definition' => [], + '#raw_options' => [], + '#content_prefix' => [], + '#content_suffix' => [], + ]; + } + + /** + * Main #pre_render callback to expand a chart element. + * + * @param array $element + * The element. + * + * @return array + * The chart element. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public function preRender(array $element) { + /** @var \Drupal\charts\Plugin\chart\Library\ChartInterface[] $definitions */ + $definitions = $this->chartsManager->getDefinitions(); + + if (!$definitions) { + $element['#type'] = 'markup'; + $element['#markup'] = $this->t('No charting library found. Enable a charting module such as Google Charts or Highcharts.'); + return $element; + } + + // Ensure there's an x and y axis to provide defaults. + $type_name = $element['#chart_type']; + $type = $this->chartsTypeManager->getDefinition($type_name); + if ($type && $type['axis'] === ChartInterface::DUAL_AXIS) { + $children_types = []; + foreach (Element::children($element) as $key) { + $children_types[] = $element[$key]['#type']; + } + + if (!in_array('chart_xaxis', $children_types)) { + $element['xaxis'] = ['#type' => 'chart_xaxis']; + } + if (!in_array('chart_yaxis', $children_types)) { + $element['yaxis'] = ['#type' => 'chart_yaxis']; + } + } + + self::castElementIntergerValues($element); + + // Generic theme function assuming it will be suitable for most chart types. + $element['#theme'] = 'charts_chart'; + // Allow the chart to be altered - @TODO use event dispatching if needed. + $alter_hooks = ['chart']; + $chart_id = $element['#chart_id']; + if ($chart_id) { + $alter_hooks[] = 'chart_' . $element['#chart_id']; + } + $this->moduleHandler->alter($alter_hooks, $element, $chart_id); + + // Include the library-specific render callback via their plugin manager. + // Use the first charting library if the requested library is not available. + $library = $element['#chart_library'] ?? ''; + $library = $this->getLibrary($library); + + $element['#chart_library'] = $library; + $charts_settings = $this->configFactory->get('charts.settings'); + $plugin_configuration = $charts_settings->get('charts_default_settings.library_config') ?? []; + /** @var \Drupal\charts\Plugin\chart\Library\ChartInterface $plugin */ + $plugin = $this->chartsManager->createInstance($library, $plugin_configuration); + if (!$plugin->isSupportedChartType($type_name)) { + // Chart type not supported by the library. + throw new \LogicException(sprintf('The provided chart type "%s" is not supported by "%s" chart plugin library.', $type_name, $plugin->getChartName())); + } + + $element = $plugin->preRender($element); + + if (!empty($element['#chart_definition'])) { + $chart_definition = $element['#chart_definition']; + unset($element['#chart_definition']); + + // Allow the chart definition to be altered - @TODO use event dispatching + // if needed. + $alter_hooks = ['chart_definition']; + if ($element['#chart_id']) { + $alter_hooks[] = 'chart_definition_' . $chart_id; + } + $this->moduleHandler->alter($alter_hooks, $chart_definition, $element, $chart_id); + // Set the element #chart_json property as a data-attribute. + $element['#attributes']['data-chart'] = Json::encode($chart_definition); + } + + $element['#cache']['tags'][] = 'config:charts.settings'; + + return $element; + } + + /** + * Casts recursively integer values. + * + * @param array $element + * The element. + */ + public static function castElementIntergerValues(array &$element) { + // Cast options to integers to avoid redundant library fixing problems. + $integer_options = [ + // Chart options. + '#title_font_size', + '#font_size', + '#legend_title_font_size', + '#legend_font_size', + '#width', + '#height', + // Axis options. + '#title_font_size', + '#labels_font_size', + '#labels_rotation', + '#max', + '#min', + // Data options. + '#decimal_count', + ]; + + foreach ($element as $property_name => $value) { + if (is_array($element[$property_name])) { + self::castElementIntergerValues($element[$property_name]); + } + elseif ($property_name && in_array($property_name, $integer_options)) { + $element[$property_name] = (is_null($element[$property_name]) || strlen($element[$property_name]) === 0) ? NULL : (int) $element[$property_name]; + } + } + } + + /** + * Trims out, recursively, empty options that aren't used. + * + * @param array $array + * The array to trim. + */ + public static function trimArray(array &$array) { + foreach ($array as $key => &$value) { + if (is_array($value)) { + self::trimArray($value); + } + elseif (is_null($value) || (is_array($value) && count($value) === 0)) { + unset($array[$key]); + } + } + } + + /** + * Get the library. + * + * @param string $library + * The library. + * + * @return string + * The library. + */ + private function getLibrary($library) { + $definitions = $this->chartsManager->getDefinitions(); + if (!$library || $library === 'site_default') { + $charts_settings = $this->configFactory->get('charts.settings'); + $default_settings_library = $charts_settings->get('charts_default_settings.library'); + $library = !empty($default_settings_library) ? $default_settings_library : key($definitions); + } + elseif (!isset($definitions[$library])) { + $library = key($definitions); + } + + return $library; + } + + /** + * Build the element. + * + * @param array $settings + * The settings. + * @param string $chart_id + * The chart id. + * + * @return array + * The element. + */ + public static function buildElement(array $settings, string $chart_id): array { + $type = $settings['type']; + $single_axis = in_array($type, ['pie', 'donut']); + $display_colors = $settings['display']['colors'] ?? []; + + $element = [ + '#type' => 'chart', + '#chart_type' => $type, + '#chart_library' => $settings['library'], + '#title' => $settings['display']['title'], + '#title_position' => $settings['display']['title_position'], + '#subtitle' => $settings['display']['subtitle'] ?? '', + '#tooltips' => $settings['display']['tooltips'] ?? [], + '#data_labels' => $settings['display']['data_labels'] ?? FALSE, + '#data_markers' => $settings['display']['data_markers'] ?? FALSE, + '#colors' => $display_colors, + '#background' => $settings['display']['background'] ?? 'transparent', + '#three_dimensional' => $settings['display']['three_dimensional'] ?? FALSE, + '#polar' => $settings['display']['polar'] ?? FALSE, + '#legend' => !empty($settings['display']['legend_position']), + '#legend_position' => $settings['display']['legend_position'] ?? '', + '#gauge' => $settings['display']['gauge'] ?? [], + '#stacking' => !empty($settings['display']['stacking']) ?? NULL, + '#width' => $settings['display']['dimensions']['width'], + '#height' => $settings['display']['dimensions']['height'], + '#width_units' => $settings['display']['dimensions']['width_units'], + '#height_units' => $settings['display']['dimensions']['height_units'], + '#color_changer' => $settings['display']['color_changer'] ?? FALSE, + ]; + + if (empty($settings['series'])) { + return $element; + } + + $table = $settings['series']; + // Extracting the categories. + $categories = ChartDataCollectorTable::getCategoriesFromCollectedTable($table, $type); + // Extracting the rest of the data. + $series_data = ChartDataCollectorTable::getSeriesFromCollectedTable($table, $type); + + $element['xaxis'] = [ + '#type' => 'chart_xaxis', + '#labels' => $single_axis ? '' : $categories['data'], + '#title' => $settings['xaxis']['title'] ?? FALSE, + '#labels_rotation' => $settings['xaxis']['labels_rotation'], + ]; + + if (empty($series_data)) { + return $element; + } + + $element['yaxis'] = [ + '#type' => 'chart_yaxis', + '#title' => $settings['yaxis']['title'] ?? '', + '#labels_rotation' => $settings['yaxis']['labels_rotation'], + '#max' => $settings['yaxis']['max'], + '#min' => $settings['yaxis']['min'], + '#prefix' => $settings['yaxis']['prefix'], + '#suffix' => $settings['yaxis']['suffix'], + '#decimal_count' => $settings['yaxis']['decimal_count'], + ]; + + // Create a secondary axis if needed. + $series_count = count($series_data); + if (!empty($settings['yaxis']['inherit']) && $series_count === 2) { + $element['secondary_yaxis'] = [ + '#type' => 'chart_yaxis', + '#title' => $settings['yaxis']['secondary']['title'] ?? '', + '#labels_rotation' => $settings['yaxis']['secondary']['labels_rotation'], + '#max' => $settings['yaxis']['secondary']['max'], + '#min' => $settings['yaxis']['secondary']['min'], + '#prefix' => $settings['yaxis']['secondary']['prefix'], + '#suffix' => $settings['yaxis']['secondary']['suffix'], + '#decimal_count' => $settings['yaxis']['secondary']['decimal_count'], + '#opposite' => TRUE, + ]; + } + + // Overriding element colors for pie and donut chart types when the + // settings display colors is empty. + $overrides_element_colors = !$display_colors && ($type === 'pie' || $type === 'donut'); + $series_key = $chart_id . '__series'; + if ($single_axis) { + $new_series = []; + $labels = []; + foreach ($series_data as $datum) { + $new_series[] = $datum['data'][0][1]; + $labels[] = $datum['name']; + if ($overrides_element_colors) { + $element['#colors'][] = $datum['color']; + } + } + $element['xaxis']['#labels'] = $labels; + // @todo Address more than one series. + $element[$series_key] = [ + '#type' => 'chart_data', + '#data' => $new_series, + '#title' => $series_data[0]['title'] ?? '', + ]; + } + else { + $series_counter = 0; + foreach ($series_data as $data_index => $data) { + $key = $series_key . '__' . $data_index; + $element[$key] = [ + '#type' => 'chart_data', + '#data' => $data['data'], + '#title' => $data['name'], + ]; + if (!empty($data['color'])) { + $element[$key]['#color'] = $data['color']; + } + if (isset($element['yaxis'])) { + $element[$key]['#prefix'] = $settings['yaxis']['prefix']; + $element[$key]['#suffix'] = $settings['yaxis']['suffix']; + $element[$key]['#decimal_count'] = $settings['yaxis']['decimal_count']; + } + if (isset($element['secondary_yaxis']) && $series_counter === 1) { + $element[$key]['#target_axis'] = 'secondary_yaxis'; + $element[$key]['#prefix'] = $settings['yaxis']['secondary']['prefix']; + $element[$key]['#suffix'] = $settings['yaxis']['secondary']['suffix']; + $element[$key]['#decimal_count'] = $settings['yaxis']['secondary']['decimal_count']; + } + $series_counter++; + } + } + + return $element; + } + +} diff --git a/web/modules/charts/src/Element/ChartAxisBase.php b/web/modules/charts/src/Element/ChartAxisBase.php new file mode 100755 index 00000000..05e50651 --- /dev/null +++ b/web/modules/charts/src/Element/ChartAxisBase.php @@ -0,0 +1,49 @@ + '', + '#title' => '', + '#title_color' => '#000', + // Options: normal, bold. + '#title_font_weight' => 'normal', + // Options: normal, italic. + '#title_font_style' => 'normal', + '#title_font_size' => 12, + // CSS value for font size, e.g. 1em or 12px. + '#labels' => NULL, + '#labels_color' => '#000', + // Options: normal, bold. + '#labels_font_weight' => 'normal', + // Options: normal, italic. + '#labels_font_style' => 'normal', + // CSS value for font size, e.g. 1em or 12px. + '#labels_font_size' => NULL, + // Integer rotation value, e.g. 30, -60 or 90. + '#labels_rotation' => NULL, + '#grid_line_color' => '#ccc', + '#base_line_color' => '#ccc', + '#minor_grid_line_color' => '#e0e0e0', + // Integer max value on this axis. + '#max' => NULL, + // Integer minimum value on this axis. + '#min' => NULL, + // Display axis on opposite normal side. + '#opposite' => FALSE, + ]; + } + +} diff --git a/web/modules/charts/src/Element/ChartData.php b/web/modules/charts/src/Element/ChartData.php new file mode 100755 index 00000000..a2d95506 --- /dev/null +++ b/web/modules/charts/src/Element/ChartData.php @@ -0,0 +1,44 @@ + NULL, + '#labels' => NULL, + '#data' => [], + '#color' => NULL, + '#show_in_legend' => TRUE, + // Show inline labels next to the data. + '#show_labels' => FALSE, + // If building multicharts. The chart type, e.g. pie. + '#chart_type' => NULL, + // Line chart only. + '#line_width' => 1, + // Line chart only. Size in pixels, e.g. 1, 5. + '#marker_radius' => 3, + // If using multiple axes, key for the matching y axis. + '#target_axis' => NULL, + // Formatting options. + // The number of digits after the decimal separator. e.g. 2. + '#decimal_count' => NULL, + // A custom date format, e.g. %Y-%m-%d. + '#date_format' => NULL, + '#prefix' => NULL, + '#suffix' => NULL, + ]; + } + +} diff --git a/web/modules/charts/src/Element/ChartDataCollectorTable.php b/web/modules/charts/src/Element/ChartDataCollectorTable.php new file mode 100755 index 00000000..fdedd7ab --- /dev/null +++ b/web/modules/charts/src/Element/ChartDataCollectorTable.php @@ -0,0 +1,909 @@ + TRUE, + // Either to enable csv import. + '#import_csv' => TRUE, + '#import_csv_separator' => ',', + // The initial number of rows to generate. + '#initial_rows' => 5, + // The initial number of columns to generate. + '#initial_columns' => 2, + // The optional element the table should be wrapped in. + '#table_wrapper' => '', + '#table_wrapper_attributes' => [], + '#table_attributes' => [], + // Allows to toggle on/off drupal tabledrag functionality. + '#table_drag' => TRUE, + '#default_colors' => [], + '#process' => [ + [$class, 'processDataCollectorTable'], + ], + '#element_validate' => [ + [$class, 'validateDataCollectorTable'], + ], + '#theme_wrappers' => ['container'], + ]; + } + + /** + * Processes the element to render a table to collect a data for the chart. + * + * @param array $element + * The element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $complete_form + * The complete form. + * + * @return array + * The processed element. + */ + public static function processDataCollectorTable(array &$element, FormStateInterface $form_state, array &$complete_form) { + $parents = $element['#parents']; + $id_prefix = implode('-', $parents); + $wrapper_id = Html::getUniqueId($id_prefix . '-ajax-wrapper'); + $value = $element['#value']; + $required = !empty($element['#required']); + $user_input = $form_state->getUserInput(); + + $element_state = self::getElementState($parents, $form_state); + // Getting columns and rows count. + if (empty($element_state['data_collector_table']) || empty($element_state['table_categories_identifier'])) { + $identifier_value = $value['table_categories_identifier'] ?? self::FIRST_COLUMN; + $element_state['table_categories_identifier'] = $identifier_value; + $element_state['data_collector_table'] = $value['data_collector_table'] ?? []; + $element_state['data_collector_table'] = $element_state['data_collector_table'] ?: self::initializeEmptyTable($element, $identifier_value); + self::setElementState($parents, $form_state, $element_state); + } + else { + // This is hack to make ajax call return the proper identifier. + $element_state['table_categories_identifier'] = $value['table_categories_identifier']; + } + + // Enforce tree. + $element = [ + '#tree' => TRUE, + '#prefix' => '
', + '#suffix' => '
', + // Pass the id along to other methods. + '#wrapper_id' => $wrapper_id, + ] + $element; + + $element['table_categories_identifier'] = [ + '#type' => 'radios', + '#title' => t('Categories are identified by'), + '#options' => [ + self::FIRST_COLUMN => t('First column'), + self::FIRST_ROW => t('First row'), + ], + '#description' => t('Select whether the first row or column hold the categories data'), + '#required' => $required, + '#default_value' => $element_state['table_categories_identifier'], + '#ajax' => [ + 'callback' => [get_called_class(), 'ajaxRefresh'], + 'progress' => ['type' => 'throbber'], + 'wrapper' => $wrapper_id, + 'effect' => 'fade', + ], + ]; + + $table = [ + '#type' => 'table', + '#tree' => TRUE, + '#header' => [], + '#responsive' => FALSE, + '#attributes' => [ + 'class' => ['data-collector-table'], + ], + ]; + + $table_drag = $element['#table_drag']; + $table_drag_group = Html::cleanCssIdentifier($id_prefix . '-order-weight'); + if ($table_drag) { + $table['#tabledrag'] = [ + [ + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => $table_drag_group, + ], + ]; + } + + if ($element['#table_wrapper'] === 'container') { + $element['table_wrapper'] = [ + '#type' => 'container', + '#attributes' => $element['#table_wrapper_attributes'], + '#tree' => FALSE, + ]; + $element['table_wrapper']['data_collector_table'] = &$table; + } + else { + $element['data_collector_table'] = &$table; + } + + $rows = count($element_state['data_collector_table']); + + // Make the weight list always reflect the current number of values. + $max_weight = count($element_state['data_collector_table']); + $max_row = max(array_keys($element_state['data_collector_table'])); + + // The first column need to be for colors. + $is_first_column = $element_state['table_categories_identifier'] === self::FIRST_COLUMN; + $first_row_key = NULL; + foreach ($element_state['data_collector_table'] as $i => $row) { + $first_row_key = $first_row_key ?? $i; + $table_first_row = $i === $first_row_key; + $add_color_first_row = ($is_first_column && $table_first_row); + $first_col_key = NULL; + + $row_form = &$table[$i]; + $row_form['#attributes']['class'][] = 'data-collector-table--row'; + + // Adding the row textfield cells. + foreach ($row as $j => $column) { + if ($j === 'weight') { + continue; + } + + $first_col_key = $first_col_key ?? $j; + $table_first_col = $j === $first_col_key; + // To be used to skip color input on cell[0][0]. + $is_category_cell = $table_first_col && $table_first_row; + $row_form[$j]['data'] = [ + '#type' => 'textfield', + '#title' => t('Data for column @col - Row @row', [ + '@row' => $i, + '@col' => $j, + ]), + '#title_display' => 'invisible', + '#size' => 10, + '#default_value' => is_array($column) ? $column['data'] : $column, + '#wrapper_attributes' => [ + 'class' => ['data-collector-table--row--cell'], + ], + ]; + + if (!$is_category_cell && ($add_color_first_row || (!$is_first_column && $j === $first_col_key))) { + if (empty($column['color'])) { + $color_index = $is_first_column ? $j : $i; + $column['color'] = $element['#default_colors'][$color_index - 1] ?? self::randomColor(); + } + $row_form[$j]['#wrapper_attributes'] = [ + 'class' => ['container-inline'], + ]; + $row_form[$j]['color'] = [ + '#type' => 'textfield', + '#title' => t('Color'), + '#title_display' => 'invisible', + '#attributes' => [ + 'TYPE' => 'color', + 'style' => 'min-width:50px;', + ], + '#size' => 10, + '#maxlength' => 7, + '#default_value' => $column['color'], + ]; + } + } + + // Adding weight if table drag enabled. + if ($table_drag) { + $row_form['#attributes']['class'][] = 'draggable'; + if (($i + 1) === $rows) { + $default_weight = $max_weight; + } + else { + $default_weight = $max_row + 1; + } + $row_form['weight'] = [ + '#type' => 'weight', + '#title' => t('Weight'), + '#title_display' => 'invisible', + '#delta' => $max_weight, + '#default_value' => $element_state['data_collector_table'][$i]['weight'] ?? $default_weight, + '#attributes' => [ + 'class' => [$table_drag_group], + ], + ]; + // Used by SortArray::sortByWeightProperty to sort the rows. + if (isset($user_input['data_collector_table'][$i])) { + $input_weight = $user_input['data_collector_table'][$i]['weight']; + // Make sure the weight is not out of bounds due to removals. + if ($user_input['data_collector_table'][$i]['weight'] > $max_weight) { + $input_weight = $max_weight; + } + // Reflect the updated user input on the element. + $row_form['weight']['#value'] = $input_weight; + $row_form['#weight'] = $input_weight; + } + else { + $row_form['#weight'] = $default_weight; + } + } + + // Row delete button. + $row_form['delete'] = self::buildOperationButton('delete', 'row', $id_prefix, $wrapper_id, $i, [], [ + 'class' => ['data-collector-table--row--delete'], + ]); + } + + $colspan = 1; + if ($table_drag) { + // Sort the values by weight. Ensures weight is preserved on ajax refresh. + uasort($table, [ + '\Drupal\Component\Utility\SortArray', + 'sortByWeightProperty', + ]); + + // Increasing colspan when weight column is added. + $colspan = 2; + } + + // Building the column delete button. + $table['_delete_column_buttons'] = [ + '#attributes' => ['class' => ['data-collector-table--column-deletes-row']], + ]; + // Using first row to get the count of columns. + $first_row = current($element_state['data_collector_table']); + // Using array filter to exclude weight key when grabbing the row columns. + $columns = self::excludeWeightColumnFromRow($first_row); + $max_column = max(array_keys($first_row)); + foreach ($columns as $column) { + $table['_delete_column_buttons'][$column] = self::buildOperationButton('delete', 'column', $id_prefix, $wrapper_id, $column, [], [ + 'class' => ['data-collector-table--column--delete'], + ]); + if ($column === $max_column) { + $table['_delete_column_buttons'][$column]['#wrapper_attributes']['colspan'] = $colspan; + } + } + // Empty Column under delete operation placeholder. + $table['_delete_column_buttons'][$max_column + 1] = [ + '#markup' => '', + ]; + + // Footer operations. + $table['_operations'] = [ + '#attributes' => ['class' => ['data-collector-table--operations-row']], + ]; + $table['_operations']['wrapper'] = [ + '#type' => 'container', + '#wrapper_attributes' => [ + 'colspan' => count($columns) + $colspan, + ], + ]; + $table['_operations']['wrapper']['add_column'] = self::buildOperationButton('add', 'column', $id_prefix, $wrapper_id, NULL); + $table['_operations']['wrapper']['add_row'] = self::buildOperationButton('add', 'row', $id_prefix, $wrapper_id, NULL); + + if ($element['#import_csv']) { + $element['import'] = [ + '#type' => 'details', + '#title' => t('Import Data from CSV'), + '#description' => t('Note importing data from CSV will overwrite all the current data entry in the table.'), + '#open' => FALSE, + ]; + $element['import']['csv_separator'] = [ + '#type' => 'textfield', + '#title' => t('CSV separator'), + '#default_value' => $element['#import_csv_separator'] ?? ',', + '#size' => 1, + '#required' => TRUE, + ]; + $element['import']['csv'] = [ + '#name' => 'files[' . $id_prefix . ']', + '#title' => t('File upload'), + '#title_display' => 'invisible', + '#type' => 'file', + '#upload_validators' => [ + 'file_validate_extensions' => ['csv'], + 'file_validate_size' => [Environment::getUploadMaxSize()], + ], + ]; + $element['import']['upload'] = [ + '#type' => 'submit', + '#value' => t('Upload CSV'), + '#name' => $id_prefix . '-import-csv', + '#attributes' => [ + 'class' => [Html::cleanCssIdentifier($id_prefix . '--import-csv')], + ], + '#submit' => [[get_called_class(), 'importCsvToTableSubmit']], + '#limit_validation_errors' => [ + array_merge($parents, ['import', 'csv']), + array_merge($parents, ['import', 'upload']), + ], + '#ajax' => [ + 'callback' => [get_called_class(), 'ajaxRefresh'], + 'progress' => ['type' => 'throbber'], + 'wrapper' => $wrapper_id, + 'effect' => 'fade', + ], + '#operation' => 'csv', + '#csv_separator' => $element['#import_csv_separator'] ?? ',', + ]; + } + $element['#attributes']['style'] = 'overflow: auto;'; + + return $element; + } + + /** + * Validates the data collected. + * + * @param array $element + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public static function validateDataCollectorTable(array $element, FormStateInterface $form_state) { + $parents = $element['#parents']; + $value = $form_state->getValue($parents); + + // Remove empty rows and unneeded keys. + foreach ($value['data_collector_table'] as $row_key => $row) { + if (!is_numeric($row_key)) { + unset($value['data_collector_table'][$row_key]); + continue; + } + foreach ($row as $column_key => $column) { + if (!is_numeric($column_key)) { + unset($value['data_collector_table'][$row_key][$column_key]); + } + } + } + unset($value['import']); + $form_state->setValue($parents, $value); + + if ($element['#required'] && empty($value['table_categories_identifier'])) { + $form_state->setError($element['table_categories_identifier'], t('Please select how categories should be identified.')); + } + } + + /** + * Ajax callback. + */ + public static function ajaxRefresh(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $operation = $triggering_element['#operation'] ?? ''; + + if ($operation === 'csv' || (!$operation && $triggering_element['#type'] === 'radio')) { + $length = -2; + } + else { + $length = $operation === 'add' ? -4 : -3; + } + $element_parents = array_slice($triggering_element['#array_parents'], 0, $length); + return NestedArray::getValue($form, $element_parents); + } + + /** + * Submit callback for table add and delete operations. + */ + public static function tableOperationSubmit(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $operation_on = $triggering_element['#operation_on']; + $operation = $triggering_element['#operation']; + $length = $operation === 'add' ? -4 : -3; + $element_parents = array_slice($triggering_element['#parents'], 0, $length); + if (!$element_parents) { + $length = $operation == 'add' ? -4 : -3; + $element_parents = array_slice($triggering_element['#array_parents'], 0, $length); + } + $element_state = self::getElementState($element_parents, $form_state); + $index = $triggering_element['#' . $operation_on . '_index'] ?? NULL; + + if ($operation_on === 'row') { + $element_state = self::tableRowOperation($element_state, $form_state, $operation, $index); + } + else { + $element_state = self::tableColumnOperation($element_state, $form_state, $operation, $element_parents, $index); + } + + self::setElementState($element_parents, $form_state, $element_state); + $form_state->setRebuild(); + } + + /** + * Submit callback for table csv import operations. + */ + public static function importCsvToTableSubmit(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $element_parents = array_slice($triggering_element['#parents'], 0, -2); + $id_prefix = implode('-', $element_parents); + $files = \Drupal::request()->files->get('files'); + /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file_upload */ + $file_upload = $files[$id_prefix]; + + $handle = $file_upload ? fopen($file_upload->getPathname(), 'r') : NULL; + if ($handle) { + // Checking the encoding of the CSV file to be UTF-8. + $encoding = 'UTF-8'; + if (function_exists('mb_detect_encoding')) { + $file_contents = file_get_contents($file_upload->getPathname()); + $encodings = ['UTF-8', 'ISO-8859-1', 'WINDOWS-1251']; + $encodings_list = implode(',', $encodings); + $encoding = mb_detect_encoding($file_contents, $encodings_list); + } + + // Populate CSV values. + $rows_count = 0; + $element_state = []; + $user_inputs = $form_state->getUserInput(); + $series = NestedArray::getValue($user_inputs, $element_parents); + $separator = $series['import']['csv_separator']; + while ($row = fgetcsv($handle, 0, $separator)) { + foreach ($row as $column_value) { + $element_state['data_collector_table'][$rows_count][] = [ + 'data' => self::convertEncoding($column_value, $encoding), + ]; + } + $rows_count++; + } + fclose($handle); + + \Drupal::messenger()->addMessage(t('Successfully imported @file', [ + '@file' => $file_upload->getClientOriginalName(), + ])); + + // Updating form state storage. + self::setElementState($element_parents, $form_state, $element_state); + // Making sure that the user input is updated as well. + $input = $form_state->getUserInput(); + NestedArray::setValue($input, $element_parents, $element_state); + $form_state->setUserInput($input); + } + else { + \Drupal::messenger() + ->addError(t('There was a problem importing the provided file data.')); + } + + $form_state->setRebuild(); + } + + /** + * Utility method to build a button render array for the various data table. + * + * Operation. + */ + private static function buildOperationButton($operation, $on, $id_prefix, $wrapper_id, $index = NULL, $attributes = [], $wrapper_attributes = []) { + $name = $id_prefix . '_' . $operation . '_' . $on; + $submit = []; + + if (!is_null($index)) { + $name .= '_' . $index; + $submit['#' . $on . '_index'] = $index; + } + + if ($attributes) { + $submit['#attributes'] = $attributes; + } + + if ($wrapper_attributes) { + $submit['#wrapper_attributes'] = $wrapper_attributes; + } + + $value = []; + $value['add']['row'] = t('Add row'); + $value['add']['column'] = t('Add column'); + $value['delete']['row'] = t('Delete row'); + $value['delete']['column'] = t('Delete column'); + + $submit += [ + '#type' => 'submit', + '#name' => $name, + '#value' => $value[$operation][$on], + '#limit_validation_errors' => [], + '#submit' => [[get_called_class(), 'tableOperationSubmit']], + '#operation' => $operation, + '#operation_on' => $on, + '#ajax' => [ + 'callback' => [get_called_class(), 'ajaxRefresh'], + 'wrapper' => $wrapper_id, + 'effect' => 'fade', + ], + ]; + + return $submit; + } + + /** + * Initializes an empty table. + * + * @param array $element + * The element. + * @param string $identifier_value + * The identifier value. + * + * @return array + * The element state storage. + */ + private static function initializeEmptyTable(array $element, string $identifier_value) { + $is_first_column = $identifier_value === self::FIRST_COLUMN; + $columns = $element['#initial_columns']; + $columns_arr = range(0, $columns - 1); + $rows = $element['#initial_rows']; + $rows_arr = range(0, $rows - 1); + + $data = []; + $first_row_key = NULL; + $counter_default_used_color_index = 0; + $max_default_colors = count($element['#default_colors']); + foreach ($rows_arr as $i) { + $first_row_key = $first_row_key ?? $i; + $table_first_row = $i === $first_row_key; + $first_col_key = NULL; + foreach ($columns_arr as $j) { + $first_col_key = $first_col_key ?? $j; + $table_first_col = $j === $first_col_key; + // Used to skip category cell. + $is_category_cell = $table_first_col && $table_first_row; + $data[$i][$j]['data'] = ''; + if (!$is_category_cell && (($is_first_column && $i === $first_row_key) || (!$is_first_column && $j === $first_col_key))) { + if ($counter_default_used_color_index === $max_default_colors) { + $counter_default_used_color_index = 0; + } + $data[$i][$j]['color'] = $element['#default_colors'][$counter_default_used_color_index] ?? self::randomColor(); + $counter_default_used_color_index++; + } + } + } + return $data; + } + + /** + * Performs add or delete operation on the table row. + * + * @param array $element_state + * The element state storage. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param string $op + * The operation. + * @param null|int $index + * The row index. + * + * @return array + * The updated element state storage. + */ + private static function tableRowOperation(array $element_state, FormStateInterface $form_state, $op, $index = NULL) { + if ($op === 'delete') { + // When only one row left we just empty it's columns. + if (count($element_state['data_collector_table']) === 1) { + $row = $element_state['data_collector_table'][$index]; + $element_state['data_collector_table'][$index][] = self::emptyRowColumns($row); + return $element_state; + } + unset($element_state['data_collector_table'][$index]); + } + else { + $first_row = current($element_state['data_collector_table']); + $element_state['data_collector_table'][] = self::emptyRowColumns($first_row); + } + + return $element_state; + } + + /** + * Performs add or delete operation on the table column. + * + * @param array $element_state + * The element state storage. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param string $op + * The operation. + * @param array $element_parents + * The element parents. + * @param null|int $index + * The column index. + * + * @return array + * The updated element state storage. + */ + private static function tableColumnOperation(array $element_state, FormStateInterface $form_state, $op, array $element_parents, $index = NULL) { + if ($op === 'delete') { + foreach ($element_state['data_collector_table'] as $row_key => $columns) { + $row = $element_state['data_collector_table'][$row_key]; + if (count(self::excludeWeightColumnFromRow($row)) === 1) { + $element_state['data_collector_table'][$row_key][$index]['data'] = ''; + } + else { + array_splice($element_state['data_collector_table'][$row_key], $index, 1); + + // Making sure that the user input is updated as well. + $user_input = $form_state->getUserInput(); + $values = NestedArray::getValue($form_state->getUserInput(), $element_parents); + if (!empty($values['data_collector_table'][$row_key][$index])) { + array_splice($values['data_collector_table'][$row_key], $index, 1); + } + NestedArray::setValue($user_input, $element_parents, $values); + $form_state->setUserInput($user_input); + } + } + } + else { + foreach ($element_state['data_collector_table'] as $row_key => $columns) { + $element_state['data_collector_table'][$row_key][]['data'] = ''; + } + } + + return $element_state; + } + + /** + * Excludes weight column from row. + * + * @param array $row + * The row. + * + * @return array + * The columns. + */ + private static function excludeWeightColumnFromRow(array $row) { + return array_filter(array_keys($row), function ($key) { + return is_int($key); + }); + } + + /** + * Empty row columns. + * + * @param array $row + * The row. + * + * @return array + * The empty row column. + */ + private static function emptyRowColumns(array $row) { + $columns = self::excludeWeightColumnFromRow($row); + $empty_row_columns = []; + foreach ($columns as $key => $column) { + $empty_row_columns[$key]['data'] = ''; + } + return $empty_row_columns; + } + + /** + * Helper function to detect and convert strings not in UTF-8 to UTF-8. + * + * @param string $data + * The string which needs converting. + * @param string $encoding + * The encoding of the CSV file. + * + * @return string + * UTF encoded string. + */ + private static function convertEncoding($data, $encoding) { + // Converting UTF-8 to UTF-8 will not work. + if ($encoding == 'UTF-8') { + return $data; + } + + // Try to convert the data to UTF-8. + if ($encoded_data = Unicode::convertToUtf8($data, $encoding)) { + return $encoded_data; + } + + // Fallback on the input data. + return $data; + } + + /** + * Gets the categories from the data collected by this element. + * + * @param array $data + * The data. + * @param string $type + * The chart type. + * + * @return array + * The category label and data. + */ + public static function getCategoriesFromCollectedTable(array $data, string $type) { + $categories_identifier = $data['table_categories_identifier'] ?? ''; + $table = $data['data_collector_table']; + $categories = []; + + $is_first_column = $categories_identifier === self::FIRST_COLUMN; + $first_row = current($table); + $category_col_key = key($first_row); + $categories['label'] = $first_row[$category_col_key]; + $data = []; + if ($is_first_column) { + if (!in_array($type, ['pie', 'donut'])) { + // Extracting the categories data. + $col_cells = array_column($table, $category_col_key); + foreach ($col_cells as $cell) { + $data[] = is_array($cell) ? $cell['data'] : $cell; + } + } + else { + $col_cells = array_values($first_row); + foreach ($col_cells as $cell) { + $data[] = is_array($cell) ? $cell['data'] : $cell; + } + } + } + else { + $col_cells = array_values($first_row); + foreach ($col_cells as $cell) { + $data[] = is_array($cell) ? $cell['data'] : $cell; + } + } + $categories['data'] = $data; + // Removing the category label from categories. + $categories_data = $categories['data']; + array_shift($categories_data); + + $categories['data'] = $categories_data; + + return $categories; + } + + /** + * Gets the series from the data collected by this element. + * + * @param array $data + * The data. + * @param string $type + * The type of chart. + * + * @return array + * The series. + */ + public static function getSeriesFromCollectedTable(array $data, string $type) { + $table = $data['data_collector_table']; + $categories_identifier = $data['table_categories_identifier'] ?? ''; + + /** @var \Drupal\charts\TypeManager $chart_type_plugin_manager */ + $chart_type_plugin_manager = \Drupal::service('plugin.manager.charts_type'); + $chart_type = $chart_type_plugin_manager->getDefinition($type); + $is_single_axis = $chart_type['axis'] === ChartInterface::SINGLE_AXIS; + + $is_first_column = $categories_identifier === self::FIRST_COLUMN; + $first_row = current($table); + $category_col_key = key($first_row); + + // Skip the first row if it's considered as the holding categories data. + if (!$is_first_column) { + array_shift($table); + } + + $series = []; + $i = 0; + foreach ($table as $row) { + if (!$is_first_column) { + $name_key = key($row); + $series[$i]['name'] = $row[$name_key]['data'] ?? []; + $series[$i]['color'] = $row[$name_key]['color'] ?? ''; + // Removing the name from data array. + unset($row[$name_key]); + foreach ($row as $column) { + // Get all the data in this column and break out of this loop. + if ($is_single_axis) { + if (is_numeric($column) || is_string($column)) { + $series[$i]['data'][] = [ + $series[$i]['name'], + self::castValueToNumeric($column), + ]; + } + elseif (is_array($column) && isset($column['data'])) { + $series[$i]['data'][] = [ + $series[$i]['name'], + self::castValueToNumeric($column['data']), + ]; + } + } + else { + if (is_numeric($column) || is_string($column)) { + $series[$i]['data'][] = self::castValueToNumeric($column); + } + elseif (is_array($column) && isset($column['data'])) { + $series[$i]['data'][] = self::castValueToNumeric($column['data']); + } + } + } + // Adding a couple types not currently supported but hopefully soon. + if (in_array($type, [ + 'scatter', + 'bubble', + 'candlestick', + 'boxplot', + ])) { + // Enclose the data value in an array. + $series[$i]['data'] = [$series[$i]['data']]; + } + } + else { + $j = 0; + foreach ($row as $column_key => $column) { + // Skipping the category label and it's data. + if ($column_key === $category_col_key || !is_numeric($column_key)) { + continue; + } + elseif ($i === 0) { + // This is the first column which holds the data names and colors. + $series[$j]['name'] = $column['data'] ?? $column; + $series[$j]['color'] = $column['color'] ?? self::randomColor(); + } + else { + // Get all the data in this column and break out of this loop. + $cell_value = is_array($column) && isset($column['data']) ? $column['data'] : $column; + $cell_value = self::castValueToNumeric($cell_value); + if ($is_single_axis) { + $series[$j]['data'][] = [$series[$j]['name'], $cell_value]; + $series[$j]['title'][] = $row[0]['data']; + } + elseif (in_array($type, [ + 'scatter', + 'bubble', + 'candlestick', + 'boxplot', + ])) { + $series[$j]['data'][0][] = $cell_value; + } + else { + $series[$j]['data'][] = $cell_value; + } + } + $j++; + } + } + $i++; + } + + return $series; + } + + /** + * Casts string value to numeric. + * + * @param string $value + * The value. + * + * @return float|int + * The numeric value. + */ + private static function castValueToNumeric($value) { + if (is_numeric($value)) { + $value = is_int($value) ? (integer) $value : (float) $value; + } + elseif ($value === '') { + $value = NULL; + } + else { + $value = 0; + } + return $value; + } + +} diff --git a/web/modules/charts/src/Element/ChartDataItem.php b/web/modules/charts/src/Element/ChartDataItem.php new file mode 100755 index 00000000..9e9acfcc --- /dev/null +++ b/web/modules/charts/src/Element/ChartDataItem.php @@ -0,0 +1,26 @@ + NULL, + '#color' => NULL, + // Often used as content of the tooltip. + '#title' => NULL, + ]; + } + +} diff --git a/web/modules/charts/src/Element/ChartXaxis.php b/web/modules/charts/src/Element/ChartXaxis.php new file mode 100755 index 00000000..2023366a --- /dev/null +++ b/web/modules/charts/src/Element/ChartXaxis.php @@ -0,0 +1,12 @@ +getStorage(), $parents); + } + + /** + * Sets the element state. + * + * @param array $parents + * The element parents. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array $element_state + * The element state. + */ + public static function setElementState(array $parents, FormStateInterface $form_state, array $element_state): void { + $parents = array_merge(['element_state', '#parents'], $parents); + NestedArray::setValue($form_state->getStorage(), $parents, $element_state); + } + +} diff --git a/web/modules/charts/src/Event/ChartsEvents.php b/web/modules/charts/src/Event/ChartsEvents.php new file mode 100755 index 00000000..cd662bf3 --- /dev/null +++ b/web/modules/charts/src/Event/ChartsEvents.php @@ -0,0 +1,21 @@ +types = $types; + } + + /** + * Gets the condition definitions. + * + * @return array + * The condition definitions. + */ + public function getTypes(): array { + return $this->types; + } + + /** + * Sets the condition definitions. + * + * @param array $types + * The condition definitions. + * + * @return $this + */ + public function setTypes(array $types) { + $this->types = $types; + return $this; + } + + /** + * Gets a chart type. + * + * @param string $type + * The chart type name. + * + * @return array + * The chart type info. + */ + public function getType($type): array { + return $this->types[$type] ?? []; + } + + /** + * Sets a chart type information. + * + * @param string $type + * The chart type name. + * @param array $info + * The chart type information settings. + * + * @return $this + */ + public function setType($type, array $info) { + if ($this->getType($type)) { + $this->types[$type] = $info; + } + return $this; + } + +} diff --git a/web/modules/charts/src/EventSubscriber/ConfigImportSubscriber.php b/web/modules/charts/src/EventSubscriber/ConfigImportSubscriber.php new file mode 100644 index 00000000..cb8a44b9 --- /dev/null +++ b/web/modules/charts/src/EventSubscriber/ConfigImportSubscriber.php @@ -0,0 +1,87 @@ +chartPluginManager = $chart_manager; + $this->chartTypePluginManager = $chart_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + ConfigEvents::STORAGE_TRANSFORM_IMPORT => ['onImportTransform'], + // There is no specific reason for choosing 50 beside it should be + // executed before \Drupal\Core\EventSubscriber::onConfigImporterImport() + // set at 40. + ConfigEvents::IMPORT => ['onConfigImporterImport', 50], + ]; + } + + /** + * Ensure the config dependencies are calculated for charts settings. + * + * @param \Drupal\Core\Config\ConfigImporterEvent $event + * The event to process. + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function onConfigImporterImport(ConfigImporterEvent $event) { + $config_importer = $event->getConfigImporter(); + $storage_comparer = $config_importer->getStorageComparer(); + $source_storage = $storage_comparer->getSourceStorage(); + $charts_config = $source_storage->read('charts.settings'); + if ($settings = $charts_config['charts_default_settings'] ?? []) { + $target_storage = $storage_comparer->getTargetStorage(); + $library = $settings['library'] ?? ''; + $type = $settings['type'] ?? ''; + $charts_config['dependencies'] = $this->calculateDependencies($library, $type); + $target_storage->write('charts.settings', $charts_config); + } + } + + /** + * Acts when the storage is transformed for import. + * + * @param \Drupal\Core\Config\StorageTransformEvent $event + * The config storage transform event. + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function onImportTransform(StorageTransformEvent $event) { + $storage = $event->getStorage(); + if ($charts_config = $storage->read('charts.settings')) { + $settings = $charts_config['charts_default_settings']; + $library = $settings['library'] ?? ''; + $type = $settings['type'] ?? ''; + $charts_config['dependencies'] = $this->calculateDependencies($library, $type); + $storage->write('charts.settings', $charts_config); + } + } + +} diff --git a/web/modules/charts/src/Form/ChartsConfigAdvancedForm.php b/web/modules/charts/src/Form/ChartsConfigAdvancedForm.php new file mode 100644 index 00000000..4712c4c4 --- /dev/null +++ b/web/modules/charts/src/Form/ChartsConfigAdvancedForm.php @@ -0,0 +1,72 @@ +config('charts.settings'); + $form['advanced'] = [ + '#type' => 'container', + '#tree' => TRUE, + ]; + $form['advanced']['debug'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable Charts Debug'), + '#description' => $this->t("Show the JSON generated for the chart in a code block below the chart."), + '#default_value' => $config->get('advanced.debug'), + ]; + $form['advanced']['requirements'] = [ + '#type' => 'details', + '#title' => $this->t('Requirement settings'), + '#description' => $this->t('The below requirements are checked by the Status report.', [':href' => Url::fromRoute('system.status')->toString()]), + '#open' => TRUE, + '#tree' => TRUE, + ]; + $form['advanced']['requirements']['cdn'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Use a CDN by default for external libraries'), + '#description' => $this->t('If checked, the module will use a CDN unless a local copy of the library is present. If unchecked, all warnings about missing libraries will be disabled.') . '

' . $this->t('Relying on a CDN (content delivery network) for external libraries can cause unexpected issues with Ajax and BigPipe support. For more information see: Issue #1988968', [':href' => 'https://www.drupal.org/project/drupal/issues/1988968']), + '#return_value' => TRUE, + '#default_value' => $config->get('advanced.requirements.cdn'), + ]; + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $advanced = $form_state->getValue('advanced'); + + $config = $this->config('charts.settings'); + $config->set('advanced', $advanced)->save(); + + parent::submitForm($form, $form_state); + } + +} diff --git a/web/modules/charts/src/Form/ChartsConfigForm.php b/web/modules/charts/src/Form/ChartsConfigForm.php new file mode 100755 index 00000000..2c4aa24f --- /dev/null +++ b/web/modules/charts/src/Form/ChartsConfigForm.php @@ -0,0 +1,203 @@ +cacheTagsInvalidator = $cache_tags_invalidator; + // @todo Implement full if statement for optional parameters, and add + // deprecation warnings when they are not passed. + $this->chartPluginManager = $chart_plugin_manager ?: \Drupal::service('plugin.manager.charts'); + $this->chartTypePluginManager = $chart_type_plugin_manager ?: \Drupal::service('plugin.manager.charts_type'); + $this->moduleExtensionList = $module_extension_list ?: \Drupal::service('extension.list.module'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('config.typed'), + $container->get('cache_tags.invalidator'), + $container->get('plugin.manager.charts'), + $container->get('plugin.manager.charts_type'), + $container->get('extension.list.module') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'charts_form_base'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['charts.settings']; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $default_config = $this->config('charts.settings')->get('charts_default_settings') ?: []; + + $form['help'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t('The settings on this page are used to set + default settings. They do not affect existing charts. + To make a new chart, create a new view and select the display format of + "Chart." Or use a Charts Block and add your own data inside that block. + You can also attach a Chart field to your content (or other entity) + type and add your data within the Chart field.'), + ]; + $form['settings'] = [ + '#type' => 'charts_settings', + '#used_in' => 'config_form', + '#required' => TRUE, + '#default_value' => $default_config, + ]; + + $form['actions']['reset_to_default'] = [ + '#type' => 'submit', + '#submit' => ['::submitReset'], + '#value' => $this->t('Reset to default configurations'), + '#weight' => 100, + '#access' => !empty($default_config['library']), + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $settings = $form_state->getValue('settings'); + if (empty($settings['library'])) { + $form_state->setError($form['settings'], $this->t('Please select a library to use by default or install a module implementing a chart library plugin.')); + } + parent::validateForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $settings = $form_state->getValue('settings'); + // The settings form element is returning an unneeded 'defaults' value. + if (isset($settings['defaults'])) { + unset($settings['defaults']); + } + + // Process the default colors to remove unneeded data. + foreach ($settings['display']['colors'] as $color_index => $color_item) { + $settings['display']['colors'][$color_index] = $color_item['color']; + } + + // Save the main settings. + $config = $this->config('charts.settings'); + $config->set('dependencies', $this->calculateDependencies($settings['library'], $settings['type'])) + ->set('charts_default_settings', $settings) + ->save(); + + // Invalidate cache tags to refresh any view relying on this. + $this->cacheTagsInvalidator->invalidateTags($config->getCacheTags()); + + parent::submitForm($form, $form_state); + } + + /** + * Reset submit callback. + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public function submitReset(array &$form, FormStateInterface $form_state) { + $path = $this->moduleExtensionList->getPath('charts'); + $default_install_settings_file = $path . '/config/install/charts.settings.yml'; + if (!file_exists($default_install_settings_file)) { + $this->messenger()->addWarning($this->t('We could not reset the configuration to default because the default settings file does not exist. Please re-download the charts module files.')); + return; + } + + $config = $this->config('charts.settings'); + $default_install_settings = Yaml::decode(file_get_contents($default_install_settings_file)); + $config->set('charts_default_settings', $default_install_settings['charts_default_settings']) + ->set('dependencies', $default_install_settings['dependencies']) + ->save(); + + $this->messenger()->addStatus($this->t('The charts configuration were successfully reset to default.')); + } + +} diff --git a/web/modules/charts/src/Plugin/DataType/ChartConfigData.php b/web/modules/charts/src/Plugin/DataType/ChartConfigData.php new file mode 100755 index 00000000..4ac15092 --- /dev/null +++ b/web/modules/charts/src/Plugin/DataType/ChartConfigData.php @@ -0,0 +1,25 @@ +getEntity(); + $entity_uuid = $entity->uuid(); + $entity_type_id = $entity->getEntityTypeId(); + $bundle = $entity->bundle(); + $field_name = $items->getName(); + $chart_id = $entity_type_id . '__' . $bundle; + + foreach ($items as $delta => $item) { + $id = 'charts-item--' . $entity_uuid . '--' . $delta; + $elements[$delta] = $this->viewElement($item, $chart_id); + $elements[$delta]['#id'] = Html::getUniqueId($id); + $elements[$delta]['#chart_id'] = $chart_id; + $elements[$delta]['#entity'] = $entity; + $elements[$delta]['#field_name'] = $field_name; + } + + return $elements; + } + + /** + * Builds a renderable array for a single chart item. + * + * @param \Drupal\Core\Field\FieldItemInterface $item + * The chart field item. + * @param string $chart_id + * The chart id. + * + * @return array + * A renderable array. + * + * @throws \Drupal\Core\TypedData\Exception\MissingDataException + */ + protected function viewElement(FieldItemInterface $item, string $chart_id) { + $settings = $item->toArray()['config']; + if ($this->hasData($settings['series']['data_collector_table'])) { + return Chart::buildElement($settings, $chart_id); + } + return []; + } + + /** + * Checks if the chart has data. + * + * @param array $data_collector_table + * The data collector table. + * + * @return bool + * TRUE if the chart has data, FALSE otherwise. + */ + protected function hasData(array $data_collector_table) { + foreach ($data_collector_table as $row) { + foreach ($row as $cell) { + if (!empty($cell['data'])) { + return TRUE; + } + } + } + return FALSE; + } + +} diff --git a/web/modules/charts/src/Plugin/Field/FieldType/ChartConfigItem.php b/web/modules/charts/src/Plugin/Field/FieldType/ChartConfigItem.php new file mode 100755 index 00000000..18044dd9 --- /dev/null +++ b/web/modules/charts/src/Plugin/Field/FieldType/ChartConfigItem.php @@ -0,0 +1,94 @@ +setLabel(new TranslatableMarkup('Chart configuration')) + ->setRequired(TRUE); + $properties['library'] = DataDefinition::create('string') + ->setLabel(t('Chart library')); + $properties['type'] = DataDefinition::create('string') + ->setLabel(t('Chart type')); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function mainPropertyName() { + return 'config'; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + return [ + 'columns' => [ + 'config' => [ + 'type' => 'blob', + 'size' => 'big', + 'not null' => TRUE, + 'serialize' => TRUE, + ], + 'library' => [ + 'description' => 'The chart library.', + 'type' => 'varchar_ascii', + 'length' => 255, + 'not null' => TRUE, + ], + 'type' => [ + 'description' => 'The chart type.', + 'type' => 'varchar_ascii', + 'length' => 255, + 'not null' => TRUE, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + return empty($this->config); + } + + /** + * {@inheritdoc} + */ + public function setValue($values, $notify = TRUE) { + if (is_array($values['config'])) { + $values += [ + 'library' => $values['config']['library'] ?? NULL, + 'type' => $values['config']['type'] ?? NULL, + ]; + } + + parent::setValue($values, $notify); + } + +} diff --git a/web/modules/charts/src/Plugin/Field/FieldWidget/ChartConfigItemDefaultWidget.php b/web/modules/charts/src/Plugin/Field/FieldWidget/ChartConfigItemDefaultWidget.php new file mode 100755 index 00000000..f0f48307 --- /dev/null +++ b/web/modules/charts/src/Plugin/Field/FieldWidget/ChartConfigItemDefaultWidget.php @@ -0,0 +1,151 @@ +configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['third_party_settings'], + $container->get('config.factory') + ); + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'change_default_library' => TRUE, + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $elements = parent::settingsForm($form, $form_state); + + $elements['change_default_library'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Allow users to change the default charting library'), + '#description' => $this->t('The default charting library can be updated at the chart settings page.'), + '#default_value' => $this->getSetting('change_default_library'), + ]; + + return $elements; + } + + /** + * {@inheritdoc} + */ + public function settingsSummary() { + $summary = []; + + $change_default_library = $this->getSetting('change_default_library'); + if (empty($change_default_library)) { + $summary[] = $this->t('User is not allowed to change/set the default charting library'); + } + else { + $summary[] = $this->t('User is allowed to change/set the charting library'); + } + + return $summary; + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + $item = $items[$delta]; + $value = !is_null($item->toArray()['config']) ? $item->toArray()['config'] : []; + $change_default_library = $this->getSetting('change_default_library'); + $library = ''; + + // Build default settings. + $charts_settings = $this->configFactory->get('charts.settings'); + $charts_default_settings = $charts_settings->get('charts_default_settings') ?? []; + $value = NestedArray::mergeDeep($charts_default_settings, $value) ?? []; + + // Specify the library. + if (empty($change_default_library) && !empty($charts_default_settings['library'])) { + $library = $charts_default_settings['library']; + } + + $element += [ + '#type' => 'details', + '#open' => TRUE, + ]; + $element['config'] = [ + '#type' => 'charts_settings', + '#used_in' => 'basic_form', + '#required' => $element['#required'], + '#series' => TRUE, + '#default_value' => $value, + '#library' => $library, + ]; + + // Make the element none required for all at the default value widget. + if ($this->isDefaultValueWidget($form_state)) { + $element['config']['#required'] = FALSE; + } + + return $element; + } + +} diff --git a/web/modules/charts/src/Plugin/chart/Library/ChartBase.php b/web/modules/charts/src/Plugin/chart/Library/ChartBase.php new file mode 100755 index 00000000..8bee2208 --- /dev/null +++ b/web/modules/charts/src/Plugin/chart/Library/ChartBase.php @@ -0,0 +1,152 @@ +pluginDefinition['name']; + } + + /** + * {@inheritdoc} + */ + public function getSupportedChartTypes(): array { + $types = $this->pluginDefinition['types']; + $chart_plugin_id = $this->getPluginId(); + // @todo Add dependency injection for the next major version. + \Drupal::moduleHandler()->alter('charts_plugin_supported_chart_types', $types, $chart_plugin_id); + return $types; + } + + /** + * {@inheritdoc} + */ + public function isSupportedChartType(string $chart_type_id): bool { + $supported_chart_types = $this->getSupportedChartTypes(); + return !$supported_chart_types || in_array($chart_type_id, $supported_chart_types); + } + + /** + * {@inheritdoc} + */ + public function getConfiguration(): array { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration): void { + $this->configuration = NestedArray::mergeDeep($this->defaultConfiguration(), $configuration); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return []; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + } + + /** + * Gets defaults settings. + * + * @return array + * The defaults settings. + */ + public static function getDefaultSettings(): array { + return [ + 'type' => 'line', + 'library' => NULL, + 'grouping' => FALSE, + 'fields' => [ + 'label' => NULL, + 'data_providers' => NULL, + ], + 'display' => [ + 'title' => '', + 'title_position' => 'out', + 'data_labels' => FALSE, + 'data_markers' => TRUE, + 'legend' => TRUE, + 'legend_position' => 'right', + 'background' => '', + 'three_dimensional' => FALSE, + 'polar' => FALSE, + 'tooltips' => TRUE, + 'tooltips_use_html' => FALSE, + 'dimensions' => [ + 'width' => NULL, + 'width_units' => '%', + 'height' => NULL, + 'height_units' => 'px', + ], + 'gauge' => [ + 'green_to' => 100, + 'green_from' => 85, + 'yellow_to' => 85, + 'yellow_from' => 50, + 'red_to' => 50, + 'red_from' => 0, + 'max' => 100, + 'min' => 0, + ], + 'colors' => self::getDefaultColors(), + ], + ]; + } + + /** + * Gets the default hex colors. + * + * @return array + * The hex colors. + */ + public static function getDefaultColors(): array { + return [ + '#2f7ed8', + '#0d233a', + '#8bbc21', + '#910000', + '#1aadce', + '#492970', + '#f28f43', + '#77a1e5', + '#c42525', + '#a6c96a', + ]; + } + +} diff --git a/web/modules/charts/src/Plugin/chart/Library/ChartInterface.php b/web/modules/charts/src/Plugin/chart/Library/ChartInterface.php new file mode 100755 index 00000000..1afc6eef --- /dev/null +++ b/web/modules/charts/src/Plugin/chart/Library/ChartInterface.php @@ -0,0 +1,69 @@ +pluginDefinition['id']; + } + + /** + * {@inheritdoc} + */ + public function getLabel() { + return $this->pluginDefinition['label']; + } + + /** + * {@inheritdoc} + */ + public function getAxis() { + return $this->pluginDefinition['axis']; + } + + /** + * {@inheritdoc} + */ + public function isAxisInverted() { + return $this->pluginDefinition['axis_inverted'] == TRUE; + } + + /** + * {@inheritdoc} + */ + public function supportStacking() { + return $this->pluginDefinition['stacking'] == TRUE; + } + +} diff --git a/web/modules/charts/src/Plugin/chart/Type/TypeInterface.php b/web/modules/charts/src/Plugin/chart/Type/TypeInterface.php new file mode 100755 index 00000000..e75aece7 --- /dev/null +++ b/web/modules/charts/src/Plugin/chart/Type/TypeInterface.php @@ -0,0 +1,50 @@ + '1']; + + // Set the default style plugin to 'chart'. + $options['style']['contains']['type']['default'] = 'chart'; + $options['defaults']['default']['style'] = FALSE; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function execute() { + return $this->view->render($this->display['id']); + } + + /** + * {@inheritdoc} + */ + public function optionsSummary(&$categories, &$options) { + parent::optionsSummary($categories, $options); + + $categories['attachment'] = [ + 'title' => $this->t('Chart settings'), + 'column' => 'second', + 'build' => ['#weight' => -10], + ]; + $displays = array_filter($this->getOption('displays')); + if (count($displays) > 1) { + $attach_to = $this->t('Multiple displays'); + } + elseif (count($displays) == 1) { + $display = array_shift($displays); + if ($display = $this->view->storage->getDisplay($display)) { + $attach_to = $display['display_title']; + } + } + if (!isset($attach_to)) { + $attach_to = $this->t('Not defined'); + } + $options['displays'] = [ + 'category' => 'attachment', + 'title' => $this->t('Parent display'), + 'value' => $attach_to, + ]; + + $options['inherit_yaxis'] = [ + 'category' => 'attachment', + 'title' => $this->t('Axis settings'), + 'value' => $this->getOption('inherit_yaxis') ? $this->t('Use primary Y-axis') : $this->t('Create secondary axis'), + ]; + + unset($options['attachment_position']); + + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + + switch ($form_state->get('section')) { + case 'displays': + $form['#title'] .= $this->t('Parent display'); + break; + + case 'inherit_yaxis': + $form['#title'] .= $this->t('Axis settings'); + $form['inherit_yaxis'] = [ + '#title' => $this->t('Y-Axis settings'), + '#type' => 'radios', + '#options' => [ + 1 => $this->t('Inherit primary of parent display'), + 0 => $this->t('Create a secondary axis'), + ], + '#default_value' => $this->getOption('inherit_yaxis'), + '#description' => $this->t('In most charts, the x- and y-axis from the parent display are both shared with each attached child chart. However, if this chart is going to use a different unit of measurement, a secondary axis may be added on the opposite side of the normal y-axis. Only create a secondary y-axis on the first chart attachment. You can rearrange displays if needed.'), + ]; + break; + } + + } + + /** + * {@inheritdoc} + */ + public function submitOptionsForm(&$form, FormStateInterface $form_state) { + // It is very important to call the parent function here: + parent::submitOptionsForm($form, $form_state); + $section = $form_state->get('section'); + switch ($section) { + case 'displays': + $form_state->setValue($section, array_filter($form_state->getValue($section))); + break; + + // @todo set isDefaulted to false by default. + case 'inherit_arguments': + case 'inherit_exposed_filters': + case 'inherit_pager': + case 'inherit_yaxis': + $this->setOption($section, $form_state->getValue($section)); + break; + } + + } + + /** + * {@inheritdoc} + */ + public function attachTo(ViewExecutable $view, $display_id, array &$build) { + + $displays = $this->getOption('displays'); + if (empty($displays[$display_id])) { + return; + } + + if (!$this->access()) { + return; + } + + } + +} diff --git a/web/modules/charts/src/Plugin/views/field/BubbleField.php b/web/modules/charts/src/Plugin/views/field/BubbleField.php new file mode 100644 index 00000000..9633f6a9 --- /dev/null +++ b/web/modules/charts/src/Plugin/views/field/BubbleField.php @@ -0,0 +1,218 @@ +messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('messenger') + ); + } + + /** + * Sets the initial field data at zero. + */ + public function query() { + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + $this->field_alias = 'bubble_field'; + $options['fieldset_one']['default'] = NULL; + $options['fieldset_two']['default'] = NULL; + $options['fieldset_three']['default'] = NULL; + $options['fieldset_one']['x_axis'] = ['default' => NULL]; + $options['fieldset_two']['y_axis'] = ['default' => NULL]; + $options['fieldset_three']['z_axis'] = ['default' => NULL]; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + $fieldList = $this->displayHandler->getFieldLabels(); + unset($fieldList['field_charts_fields_bubble']); + + $form['fieldset_one'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Select the field representing the X axis.'), + '#collapsible' => FALSE, + '#collapsed' => FALSE, + '#weight' => -10, + '#required' => TRUE, + ]; + $form['fieldset_one']['x_axis'] = [ + '#type' => 'radios', + '#title' => $this->t('X Axis Field'), + '#options' => $fieldList, + '#default_value' => $this->options['fieldset_one']['x_axis'], + '#weight' => -10, + ]; + + $form['fieldset_two'] = [ + '#type' => 'fieldset', + '#collapsible' => FALSE, + '#collapsed' => FALSE, + '#title' => $this->t('Select the field representing the Y axis.'), + '#weight' => -9, + '#required' => TRUE, + ]; + $form['fieldset_two']['y_axis'] = [ + '#type' => 'radios', + '#title' => $this->t('Y Axis Field'), + '#options' => $fieldList, + '#default_value' => $this->options['fieldset_two']['y_axis'], + '#weight' => -9, + ]; + + $form['fieldset_three'] = [ + '#type' => 'fieldset', + '#collapsible' => FALSE, + '#collapsed' => FALSE, + '#title' => $this->t('Select the field representing the Z axis.'), + '#weight' => -9, + '#required' => TRUE, + ]; + $form['fieldset_three']['z_axis'] = [ + '#type' => 'radios', + '#title' => $this->t('Z Axis Field'), + '#options' => $fieldList, + '#default_value' => $this->options['fieldset_three']['z_axis'], + '#weight' => -9, + ]; + + return $form; + } + + /** + * Get the value of a simple math field. + * + * @param \Drupal\views\ResultRow $values + * Row results. + * @param string $fieldset + * The items fieldset. + * @param string $axis + * Whether we are fetching field one's value. + * + * @return mixed + * The field value. + * + * @throws \Exception + */ + protected function getFieldValue(ResultRow $values, $fieldset, $axis) { + + $field = $this->options[$fieldset][$axis]; + + $data = NULL; + + // Fetch the data from the database alias. + if (isset($this->view->field[$field])) { + if ($field == 'bubble_field') { + $data = $this->view->field['field_charts_fields_bubble']->getValue($values); + } + else { + $data = $this->view->field[$field]->getValue($values); + } + } + + if (!isset($data)) { + // There's no value. Default to 0. + $data = 0; + } + + // Ensure the input is numeric. + if (!empty($data) && !is_numeric($data)) { + $this->messenger->addError($this->t('Check the formatting of your + Bubble Field inputs: one or both of them are not numeric.')); + } + + return $data; + } + + /** + * {@inheritdoc} + * + * @throws \Exception + */ + public function getValue(ResultRow $values, $field = NULL) { + parent::getValue($values, $field); + + $xAxisFieldValue = $this->getFieldValue($values, 'fieldset_one', 'x_axis'); + $yAxisFieldValue = $this->getFieldValue($values, 'fieldset_two', 'y_axis'); + $zAxisFieldValue = $this->getFieldValue($values, 'fieldset_three', 'z_axis'); + + return Json::encode([ + Json::decode($xAxisFieldValue), + Json::decode($yAxisFieldValue), + Json::decode($zAxisFieldValue), + ]); + } + + /** + * Set the data type for the chart field to be an array. + * + * @return string + * The data type. + */ + public function getChartFieldDataType(): string { + return 'array'; + } + +} diff --git a/web/modules/charts/src/Plugin/views/field/ExposedChartType.php b/web/modules/charts/src/Plugin/views/field/ExposedChartType.php new file mode 100755 index 00000000..91727cc1 --- /dev/null +++ b/web/modules/charts/src/Plugin/views/field/ExposedChartType.php @@ -0,0 +1,153 @@ +options['label'] ? $this->options['label'] : 'Chart Type'; + $selected_options = $this->options['chart_types']; + $style_plugin = $this->view->style_plugin; + $settings = $style_plugin->options['chart_settings'] ?? []; + + $all_types = BaseSettings::getChartTypes($settings['library']); + $options = array_filter($all_types, function ($key) use ($selected_options) { + return in_array($key, $selected_options, TRUE); + }, ARRAY_FILTER_USE_KEY); + + $chart_plugin_selected_type = $settings['type'] ?? ''; + if ($chart_plugin_selected_type) { + // Move the selected. + if (isset($options[$chart_plugin_selected_type])) { + $options = [ + $chart_plugin_selected_type => $options[$chart_plugin_selected_type], + ] + $options; + } + else { + $options = [ + $chart_plugin_selected_type => $all_types[$chart_plugin_selected_type], + ] + $options; + } + } + + $form['ct'] = [ + '#title' => $this->t('@value', ['@value' => $label]), + '#type' => $this->options['exposed_select_type'], + '#options' => $options, + '#weight' => -20, + ]; + + if ($this->options['exposed_select_type'] == 'radios') { + $form['ct']['#attributes']['class'] = [ + 'chart-type-radios', + 'container-inline', + ]; + } + + $form['ect'] = [ + '#type' => 'hidden', + '#default_value' => 1, + ]; + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + + $options['chart_types'] = ['default' => []]; + $options['exposed_select_type'] = ['default' => 'checkboxes']; + $options['expose'] = ['default' => ['identifier' => 'ct']]; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + + $style_plugin = $this->view->style_plugin; + $settings = $style_plugin->options['chart_settings'] ?? []; + + $form['chart_types'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Chart Type Options'), + '#description' => $this->t('Pick the chart type options to be exposed. You may need to disable your Views cache.'), + '#options' => BaseSettings::getChartTypes($settings['library']), + '#default_value' => $this->options['chart_types'], + ]; + + if (!empty($settings['type'])) { + $form['chart_types'][$settings['type']] = [ + '#default_value' => $settings['type'], + '#disabled' => TRUE, + ]; + } + + $form['exposed_select_type'] = [ + '#type' => 'radios', + '#title' => $this->t('Exposed Selection Type'), + '#description' => $this->t('Choose your options widget.'), + '#options' => [ + 'radios' => $this->t('Radios'), + 'select' => $this->t('Single select'), + ], + '#default_value' => $this->options['exposed_select_type'], + ]; + } + + /** + * {@inheritdoc} + */ + public function validateOptionsForm(&$form, FormStateInterface $form_state) { + parent::validateOptionsForm($form, $form_state); + + $style_plugin = $this->view->style_plugin; + if (!($style_plugin instanceof ChartsPluginStyleChart)) { + $form_state->setError($form['chart_types'], $this->t('You can only use this field type when the selected views style is chart!')); + } + } + + /** + * {@inheritdoc} + */ + public function query() { + // This is not a real field and it does not affect the query. But Views + // won't render if the query() method is not present. This doesn't do + // anything, but it has to be here. This function is a void so it doesn't + // return anything. + } + +} diff --git a/web/modules/charts/src/Plugin/views/field/ScatterField.php b/web/modules/charts/src/Plugin/views/field/ScatterField.php new file mode 100755 index 00000000..ef39c7af --- /dev/null +++ b/web/modules/charts/src/Plugin/views/field/ScatterField.php @@ -0,0 +1,201 @@ +messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('messenger') + ); + } + + /** + * Sets the initial field data at zero. + */ + public function query() { + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + $this->field_alias = 'scatter_field'; + $options['fieldset_one']['default'] = NULL; + $options['fieldset_two']['default'] = NULL; + $options['fieldset_one']['x_axis'] = ['default' => NULL]; + $options['fieldset_two']['y_axis'] = ['default' => NULL]; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + $fieldList = $this->displayHandler->getFieldLabels(); + unset($fieldList['field_charts_fields_scatter']); + + $form['fieldset_one'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Select the field representing the X axis.'), + '#collapsible' => FALSE, + '#collapsed' => FALSE, + '#weight' => -10, + '#required' => TRUE, + ]; + $form['fieldset_one']['x_axis'] = [ + '#type' => 'radios', + '#title' => $this->t('X Axis Field'), + '#options' => $fieldList, + '#default_value' => $this->options['fieldset_one']['x_axis'], + '#weight' => -10, + ]; + + $form['fieldset_two'] = [ + '#type' => 'fieldset', + '#collapsible' => FALSE, + '#collapsed' => FALSE, + '#title' => $this->t('Select the field representing the Y axis.'), + '#weight' => -9, + '#required' => TRUE, + ]; + $form['fieldset_two']['y_axis'] = [ + '#type' => 'radios', + '#title' => $this->t('Y Axis Field'), + '#options' => $fieldList, + '#default_value' => $this->options['fieldset_two']['y_axis'], + '#weight' => -9, + ]; + + return $form; + } + + /** + * Get the value of a simple math field. + * + * @param \Drupal\views\ResultRow $values + * Row results. + * @param bool $xAxis + * Whether we are fetching field one's value. + * + * @return mixed + * The field value. + * + * @throws \Exception + */ + protected function getFieldValue(ResultRow $values, $xAxis) { + + if (!empty($xAxis)) { + $field = $this->options['fieldset_one']['x_axis']; + } + else { + $field = $this->options['fieldset_two']['y_axis']; + } + + $data = NULL; + + // Fetch the data from the database alias. + if (isset($this->view->field[$field])) { + if ($field == 'scatter_field') { + $data = $this->view->field['field_charts_fields_scatter']->getValue($values); + } + else { + $data = $this->view->field[$field]->getValue($values); + } + } + + if (!isset($data)) { + // There's no value. Default to 0. + $data = 0; + } + + // Ensure the input is numeric. + if (!empty($data) && !is_numeric($data)) { + $this->messenger->addError($this->t('Check the formatting of your + Scatter Field inputs: one or both of them are not numeric.')); + } + + return $data; + } + + /** + * {@inheritdoc} + * + * @throws \Exception + */ + public function getValue(ResultRow $values, $field = NULL) { + parent::getValue($values, $field); + + $xAxisFieldValue = $this->getFieldValue($values, TRUE); + $yAxisFieldValue = $this->getFieldValue($values, FALSE); + + return Json::encode([ + Json::decode($xAxisFieldValue), + Json::decode($yAxisFieldValue), + ]); + } + + /** + * Set the data type for the chart field to be an array. + * + * @return string + * The data type. + */ + public function getChartFieldDataType(): string { + return 'array'; + } + +} diff --git a/web/modules/charts/src/Plugin/views/style/ChartsPluginStyleChart.php b/web/modules/charts/src/Plugin/views/style/ChartsPluginStyleChart.php new file mode 100755 index 00000000..98c04b08 --- /dev/null +++ b/web/modules/charts/src/Plugin/views/style/ChartsPluginStyleChart.php @@ -0,0 +1,929 @@ +configFactory = $config_factory; + $this->chartManager = $chart_manager; + $this->chartTypeManager = $chart_type_manager; + $this->routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('config.factory'), + $container->get('plugin.manager.charts'), + $container->get('plugin.manager.charts_type'), + $container->get('current_route_match') + ); + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + $charts_settings = $this->configFactory->get('charts.settings'); + $charts_default_settings = $charts_settings->get('charts_default_settings') ?? []; + $options['chart_settings'] = [ + 'default' => $charts_default_settings, + ]; + $options['chart_settings']['fields']['allow_advanced_rendering'] = FALSE; + + $options['chart_settings']['library'] = ''; + + // @todo ensure that chart extensions inherit defaults from parent + // Remove the default setting for chart type so it can be inherited if this + // is a chart extension type. + $style_plugin = $this->view->style_plugin ?? NULL; + $style_plugin_id = $style_plugin ? $style_plugin->getPluginId() : ''; + if ($style_plugin_id === 'chart_extension') { + $options['chart_settings']['default']['type'] = NULL; + } + $options['path'] = ['default' => 'charts']; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + + $handlers = $this->displayHandler->getHandlers('field'); + if (empty($handlers)) { + $form['error_markup'] = ['#markup' => '
' . $this->t('You need at least one field before you can configure your chart settings') . '
']; + return; + } + + $settings_wrapper = 'views-charts-plugin-style-chart-options-settings-wrapper'; + // Limit grouping options (we only support one grouping field). + if (isset($form['grouping'][0])) { + $form['grouping'][0]['field']['#title'] = $this->t('Grouping field'); + $form['grouping'][0]['field']['#description'] = $this->t('If grouping by a particular field, that field will be used to determine stacking of the chart. Generally this will be the same field as what you select for the "Label field" below. If you do not have more than one "Provides data" field below, there will be nothing to stack. If you want to have another series displayed, use a "Chart attachment" display, and set it to attach to this display.'); + $form['grouping'][0]['field']['#attributes']['class'][] = 'charts-grouping-field'; + // Grouping by rendered version has no effect in charts. Hide the options. + $form['grouping'][0]['rendered']['#access'] = FALSE; + $form['grouping'][0]['rendered_strip']['#access'] = FALSE; + + // Add ajax related to grouping to allow taxonomy colors selection when + // the field is an entity reference. + $form['grouping'][0]['field']['#ajax'] = [ + 'wrapper' => $settings_wrapper, + 'callback' => [static::class, 'groupingChartSettingsAjaxCallback'], + ]; + } + if (isset($form['grouping'][1])) { + $form['grouping'][1]['#access'] = FALSE; + } + + // Merge in the global chart settings form. + $field_options = $this->displayHandler->getFieldLabels(); + $form_state->set('default_options', $this->options); + $form['chart_settings'] = [ + '#prefix' => '
', + '#type' => 'charts_settings', + '#used_in' => 'view_form', + '#required' => TRUE, + '#field_options' => $field_options, + '#default_value' => $this->options['chart_settings'], + '#suffix' => '
', + '#view_charts_style_plugin' => $this, + ]; + } + + /** + * {@inheritdoc} + */ + public function validateOptionsForm(&$form, FormStateInterface $form_state) { + parent::validateOptionsForm($form, $form_state); + + if (!$form_state->hasValue(['style_options', 'chart_settings'])) { + return; + } + + $chart_settings = $form_state->getValue(['style_options', 'chart_settings']); + $selected_library_id = $chart_settings['library'] ?? ''; + if (empty($chart_settings['library'])) { + $form_state->setError($form['chart_settings'], $this->t('Please select a valid charting library or install at least one module that implements a chart library plugin.')); + return; + } + if (($selected_library_id === 'site_default' && !BaseSettings::getConfiguredSiteDefaultLibraryId()) || empty($chart_settings['type'])) { + $destination = '/admin/structure/views/view/' . $this->view->storage->id(); + if ($this->view->current_display) { + $destination .= '/' . $this->view->current_display; + } + $form_state->setError($form['chart_settings'], $this->t('The site default charting library has not been set yet, or it does not support chart type options. Please ensure that you have correctly configured the chart module.', [ + '@url' => '/admin/config/content/charts?destination=' . $destination, + ])); + } + } + + /** + * {@inheritdoc} + */ + public function validate() { + $errors = parent::validate(); + $chart_settings = $this->options['chart_settings']; + $selected_library_id = $chart_settings['library'] ?? ''; + if (!$selected_library_id) { + $errors[] = $this->t('Please select a valid charting library.'); + return $errors; + } + + if ($selected_library_id === 'site_default' && !BaseSettings::getConfiguredSiteDefaultLibraryId()) { + $destination = '/admin/structure/views/view/' . $this->view->storage->id(); + if ($this->view->current_display) { + $destination .= '/' . $this->view->current_display; + } + $errors[] = $this->t('The site default charting library has not been set yet, or it does not support chart type options. Please ensure that you have correctly configured the chart module.', [ + '@url' => '/admin/config/content/charts?destination=' . $destination, + ]); + return $errors; + } + + $selected_data_fields = !empty($chart_settings['fields']['data_providers']) && is_array($chart_settings['fields']['data_providers']) ? + $this->getSelectedDataFields($chart_settings['fields']['data_providers']) : NULL; + + // Avoid calling validation before arriving at the view edit page. + if ($this->routeMatch->getRouteName() != 'views_ui.add' && empty($selected_data_fields)) { + $errors[] = $this->t('At least one data field must be selected in the chart configuration before this chart may be shown'); + } + + return $errors; + } + + /** + * {@inheritdoc} + */ + public function render() { + $field_handlers = $this->view->getHandlers('field'); + $chart_settings = $this->options['chart_settings']; + $chart_fields = $chart_settings['fields']; + $is_grouped = isset($this->options['grouping'][0]['field']); + + // Calculate the labels field alias. + $label_field_key = $this->getLabelFieldKey(); + + // Assemble the fields to be used to provide data access. + $field_keys = array_keys($this->getSelectedDataFields($chart_fields['data_providers'])); + $data_fields = array_filter($field_handlers, function ($field_handler) use ($field_keys, $label_field_key) { + if (isset($field_handler['exclude']) && ($field_handler['exclude'] == FALSE || $field_handler['exclude'] == 0)) { + $field_id = $field_handler['id']; + } + else { + $field_id = ''; + } + // Do not allow the label field to be used as a data field. + return $field_id !== $label_field_key && in_array($field_id, $field_keys); + }); + + $title = !empty($chart_settings['display']['title']) ? $chart_settings['display']['title'] : ''; + $subtitle = !empty($chart_settings['display']['subtitle']) ? $chart_settings['display']['subtitle'] : ''; + if (!empty($title) || !empty($subtitle)) { + $tokens = []; + $global_tokens = []; + foreach ($field_handlers as $field_id => $field_handler) { + // This needs to run or else the values are empty. + $this->getField(0, $field_id); + // If the row index is not set, set it to 0. + if (!isset($this->view->row_index)) { + $this->view->row_index = 0; + } + $render_tokens = $this->view->field[$field_id]->getRenderTokens([]) ?? []; + $global_tokens = array_merge($render_tokens, $global_tokens); + } + foreach ($global_tokens as $key => $value) { + $tokens[$key] = Xss::filterAdmin($this->tokenizeValue($key, 0)); + } + if (!empty($tokens)) { + // Allow argument tokens in the title. + $title = $this->viewsTokenReplace($title, $tokens); + // Allow argument tokens in the subtitle. + $subtitle = $this->viewsTokenReplace($subtitle, $tokens); + } + } + + // To be used with the exposed chart type field. + if ($this->view->storage->get('exposed_chart_type')) { + $chart_settings['type'] = $this->view->storage->get('exposed_chart_type'); + } + + $chart_id = $this->view->id() . '_' . $this->view->current_display; + $chart = [ + '#type' => 'chart', + '#chart_type' => $chart_settings['type'], + '#chart_library' => $chart_settings['library'], + '#chart_id' => $chart_id, + '#id' => Html::getUniqueId('chart_' . $chart_id), + '#stacking' => $chart_settings['fields']['stacking'] ?? '0', + '#polar' => $chart_settings['display']['polar'], + '#three_dimensional' => $chart_settings['display']['three_dimensional'], + '#gauge' => $chart_settings['display']['gauge'], + '#title' => $title, + '#title_position' => $chart_settings['display']['title_position'], + '#subtitle' => $subtitle, + '#tooltips' => $chart_settings['display']['tooltips'], + '#data_labels' => $chart_settings['display']['data_labels'], + '#data_markers' => $chart_settings['display']['data_markers'], + // Colors only used if a grouped view or using a type such as a pie chart. + '#colors' => $chart_settings['display']['colors'] ?? [], + '#background' => $chart_settings['display']['background'] ?? 'transparent', + '#legend' => !empty($chart_settings['display']['legend_position']), + '#legend_position' => $chart_settings['display']['legend_position'] ?? '', + '#width' => $chart_settings['display']['dimensions']['width'], + '#height' => $chart_settings['display']['dimensions']['height'], + '#width_units' => $chart_settings['display']['dimensions']['width_units'], + '#height_units' => $chart_settings['display']['dimensions']['height_units'], + '#color_changer' => $chart_settings['display']['color_changer'] ?? FALSE, + '#attributes' => ['data-drupal-selector-chart' => Html::getId($chart_id)], + // Pass info about the actual view results to allow further processing. + '#view' => $this->view, + ]; + + $chart_type = $this->chartTypeManager->getDefinition($chart_settings['type']); + if ($chart_type['axis'] === ChartInterface::SINGLE_AXIS) { + $data_field_key = key($data_fields); + $data_field = $data_fields[$data_field_key]; + $data = []; + $this->renderFields($this->view->result); + $renders = $this->rendered_fields; + if (!$label_field_key && count($data_fields) > 1) { + foreach ($data_fields as $field_id => $row) { + $data_row = []; + if (!empty($row['label'])) { + $data_row['name'] = strip_tags($row['label'], ENT_QUOTES); + } + else { + $data_row['name'] = strip_tags($field_id, ENT_QUOTES); + } + if (!empty($chart_fields['data_providers'][$field_id]['color'])) { + $data_row['color'] = $chart_fields['data_providers'][$field_id]['color']; + } + $data_row['y'] = $this->processNumberValueFromField(0, $field_id); + $data[] = $data_row; + } + } + else { + foreach ($renders as $row_number => $row) { + $data_row = []; + if ($label_field_key) { + // Labels need to be decoded; the charting library will re-encode. + $data_row[] = trim(strip_tags($this->getField($row_number, $label_field_key), ENT_QUOTES)); + } + $data_row[] = $this->processNumberValueFromField($row_number, $data_field_key); + $data[] = $data_row; + } + } + + // @todo create a textfield for chart legend title. + // if ($chart_fields['label']) { + // $chart['#legend_title'] = $chart_fields['label']; + // } + $chart[$this->view->current_display . '_series'] = [ + '#type' => 'chart_data', + '#data' => $data, + '#title' => $data_field['label'], + '#color' => isset($chart_fields['data_providers'][$data_field_key]) ? $chart_fields['data_providers'][$data_field_key]['color'] : '', + '#grouping_colors' => $this->extractGroupingColorsForSingleAxisChartType($data), + ]; + } + else { + $chart['xaxis'] = [ + '#type' => 'chart_xaxis', + '#title' => $chart_settings['xaxis']['title'] ?? '', + '#labels_rotation' => $chart_settings['xaxis']['labels_rotation'], + ]; + $chart['yaxis'] = [ + '#type' => 'chart_yaxis', + '#title' => $chart_settings['yaxis']['title'] ?? '', + '#labels_rotation' => $chart_settings['yaxis']['labels_rotation'], + '#max' => $chart_settings['yaxis']['max'], + '#min' => $chart_settings['yaxis']['min'], + ]; + + $view_records = $this->view->result; + $sets = $this->renderGrouping($view_records, $this->options['grouping'], TRUE); + if ($is_grouped) { + $this->groupedChartElementBuild($chart, $sets, $data_fields); + } + else { + $series_index = 0; + foreach ($sets as $series_label => $data_set) { + foreach ($data_fields as $field_key => $field_handler) { + $element_key = $this->view->current_display . '__' . $field_key . '_' . $series_index; + $chart[$element_key] = [ + '#type' => 'chart_data', + '#data' => [], + // If using a grouping field, inherit from the chart level colors. + '#color' => ($series_label === '' && isset($chart_fields['data_providers'][$field_key])) ? $chart_fields['data_providers'][$field_key]['color'] : '', + '#title' => $series_label ? strip_tags($series_label) : $field_handler['label'], + '#prefix' => $chart_settings['yaxis']['prefix'] ?? NULL, + '#suffix' => $chart_settings['yaxis']['suffix'] ?? NULL, + '#decimal_count' => $chart_settings['yaxis']['decimal_count'] ?? '', + ]; + } + + // Grouped results come back indexed by their original result number + // from before the grouping, so we need to keep our own row number + // when looping through the rows. + foreach ($data_set['rows'] as $result_number => $row) { + $xaxis_label = trim(strip_tags((string) $this->getField($result_number, $label_field_key))); + if ($label_field_key) { + $xaxis_labels = $chart['xaxis']['#labels'] ?? []; + if (!in_array($xaxis_label, $xaxis_labels)) { + $chart['xaxis']['#labels'][] = $xaxis_label; + } + } + foreach ($data_fields as $field_key => $field_handler) { + $element_key = $this->view->current_display . '__' . $field_key . '_' . $series_index; + $value = $this->processNumberValueFromField($result_number, $field_key); + $chart[$element_key]['#data'][] = $value; + $chart[$element_key]['#mapped_data'][$xaxis_label] = $value; + if (strpos($field_handler['id'], 'field_charts_fields_scatter') === 0 || strpos($field_handler['id'], 'field_charts_fields_bubble') === 0) { + $chart['xaxis']['#labels'] = []; + } + } + } + + // Incrementing series index. + $series_index++; + } + } + } + + // Check if this display has any children charts that should be applied + // on top of it. + $children_displays = $this->getChildrenChartDisplays(); + // Contains the different subviews of the attachments. + $attachments = []; + + foreach ($children_displays as $child_display) { + // If the user doesn't have access to the child display, skip. + if (!$this->view->access($child_display)) { + continue; + } + + // Generate the subchart by executing the child display. We load a fresh + // view here to avoid collisions in shifting the current display while in + // a display. + $subview = $this->view->createDuplicate(); + $subview->setDisplay($child_display); + $child_display_handler = $this->view->displayHandlers->get($child_display); + $child_display_settings = $subview->display_handler->options['style']['options']['chart_settings']; + + // Copy the settings for our axes over to the child view. + foreach ($chart_settings as $option_name => $option_value) { + if ($child_display_handler->options['inherit_yaxis'] === '1') { + $child_display_settings[$option_name] = $option_value; + } + } + + // Set the arguments on the subview if it is configured to inherit; + // arguments. + if (!empty($child_display_handler->display['display_options']['inherit_arguments']) && $child_display_handler->display['display_options']['inherit_arguments'] == '1') { + $subview->setArguments($this->view->args); + } + + // Execute the subview and get the result. + $subview->preExecute(); + $subview->execute(); + + // If there's no results, don't attach the subview. + if (empty($subview->result)) { + continue; + } + + $subchart = $subview->style_plugin->render(); + // Add attachment views to attachments array. + array_push($attachments, $subview); + + // Create a secondary axis if needed. + if ($child_display_handler->options['inherit_yaxis'] !== '1' && isset($subchart['yaxis'])) { + $chart['secondary_yaxis'] = $subchart['yaxis']; + $chart['secondary_yaxis']['#opposite'] = TRUE; + } + + // Merge in the child chart data. + foreach (Element::children($subchart) as $key) { + if ($subchart[$key]['#type'] === 'chart_data') { + // This ensures that chart attachment data are placed correctly, + // but it doesn't allow for chart attachment data to have x-axis + // labels not already present in the parent chart. + if (!empty($chart['xaxis']['#labels'])) { + $processed_data = $this->alignSubchartData($chart['xaxis']['#labels'], $subchart[$key]['#mapped_data'], $subchart[$key]['#data']); + $subchart[$key]['#data'] = $processed_data; + } + $chart[$key] = $subchart[$key]; + $chart[$key]['#chart_attachment_id'] = $child_display_handler->display['id']; + // If the subchart is a different type than the parent chart, set + // the #chart_type property on the individual chart data elements. + if ($subchart['#chart_type'] !== $chart['#chart_type']) { + $chart[$key]['#chart_type'] = $subchart['#chart_type']; + } + if ($child_display_handler->options['inherit_yaxis'] !== '1') { + $chart[$key]['#target_axis'] = 'secondary_yaxis'; + } + } + } + } + + // Print the chart. + return $chart; + } + + /** + * {@inheritdoc} + */ + public function renderGrouping($records, $groupings = [], $group_rendered = NULL) { + if (empty($this->options['grouping'])) { + return parent::renderGrouping($records, $groupings, $group_rendered); + } + + $xaxis_labels = []; + // Get the entire sets with grouping. + $sets = []; + // For the chart plugin the grouping level is always going to be at index + // 0 since only one grouping is allowed. + $grouping_level = 0; + $grouping_field_info = $groupings[$grouping_level]; + $grouping_field = $grouping_field_info['field']; + $xaxis_label_field_key = $this->getLabelFieldKey(); + $chart_settings = $this->options['chart_settings']; + $color_selection_method = $chart_settings['fields']['entity_grouping']['color_selection_method'] ?? ''; + + foreach ($records as $index => $row) { + $set = &$sets; + + // Extract xaxis labels. + if (isset($this->view->field[$xaxis_label_field_key])) { + $xaxis_label = $this->getField($index, $xaxis_label_field_key); + $xaxis_label = trim(strip_tags(htmlspecialchars_decode($xaxis_label))); + if (!in_array($xaxis_label, $xaxis_labels, TRUE)) { + $xaxis_labels[] = $xaxis_label; + } + $row->xaxis_label_index = array_flip($xaxis_labels)[$xaxis_label]; + } + + $grouping = ''; + $group_content = ''; + // Extract grouping content/label. + if (isset($this->view->field[$grouping_field])) { + $group_content = $this->getField($index, $grouping_field); + $group_content = $grouping = trim(strip_tags(htmlspecialchars_decode($group_content))); + } + + // Create the group if it does not exist yet. + if (empty($set[$grouping])) { + $grouping_entity_field = $this->view->field[$grouping_field]; + $group_field_name = $grouping_entity_field ? ($grouping_entity_field->definition['field_name'] ?? '') : ''; + if ($color_selection_method && $group_field_name && $grouping_entity_field instanceof EntityField && $row instanceof ResultRow) { + switch ($color_selection_method) { + case 'by_entities_on_entity_reference': + $set[$grouping]['color'] = $this->extractGroupedSelectedColorByEntity($grouping_entity_field, $row, $group_field_name); + break; + + case 'by_field_on_referenced_entity': + $set[$grouping]['color'] = $this->extractGroupedSelectedColorOnReferencedEntityField($grouping_entity_field, $row, $group_field_name); + break; + } + } + + $set[$grouping]['group'] = $group_content; + $set[$grouping]['level'] = $grouping_level; + $set[$grouping]['rows'] = []; + } + + // Move the set reference into the set of the group we just determined. + $set = &$set[$grouping]['rows']; + // Add the row to the hierarchically positioned set we just determined. + $set[$index] = $row; + } + + // Adding a workaround to pass the xaxis labels of grouping to the calling + // function. + $sets['_charts_xaxis_labels'] = $xaxis_labels; + + return $sets; + } + + /** + * Utility function to check if this chart has children displays. + * + * @return array + * Children Chart Display. + */ + public function getChildrenChartDisplays() { + $children_displays = $this->displayHandler->getAttachedDisplays(); + foreach ($children_displays as $key => $child) { + $display_handler = $this->view->displayHandlers->get($child); + // Unset disabled & non chart attachments. + if ((!$display_handler->isEnabled()) || (strstr($child, 'chart_extension') == !TRUE)) { + unset($children_displays[$key]); + } + } + + return array_values($children_displays); + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies() { + $dependencies = []; + + if (!empty($this->options['chart_settings']['library'])) { + $plugin_definition = $this->chartManager->getDefinition($this->options['chart_settings']['library']); + $dependencies['module'] = [$plugin_definition['provider']]; + } + + return $dependencies; + } + + /** + * Processes number value based on field. + * + * @param int $number + * The number. + * @param string $field + * The field. + * + * @return \Drupal\Component\Render\MarkupInterface|float|null + * The value. + */ + public function processNumberValueFromField($number, $field) { + if (is_array($this->getField($number, $field))) { + $value = $this->getField($number, $field)->__toString(); + } + else { + $value = $this->getField($number, $field); + } + + $value = trim(strip_tags($value)); + + // Get the field plugin class to determine if a Charts-specific field + // is being used. + $field_plugin = $this->view->field[$field]; + if ($field_plugin instanceof ChartViewsFieldInterface && $field_plugin->getChartFieldDataType() === 'array') { + return Json::decode($value); + } + + // Convert empty strings to NULL. + if ($value === '' || is_null($value)) { + $value = NULL; + } + else { + // Strip thousands placeholders if present, then cast to float. + $value = (float) str_replace([',', ' '], '', $value); + } + + return $value; + } + + /** + * Utility method to filter out unselected fields from data providers fields. + * + * @param array $data_providers + * The data providers. + * + * @return array + * The fields. + */ + protected function getSelectedDataFields(array $data_providers) { + return array_filter($data_providers, function ($value) { + + return !empty($value['enabled']); + }); + } + + /** + * Returns the key of the Label Field. + * + * @return string + * The Label Field key. + */ + private function getLabelFieldKey() { + if (!isset($this->labelFieldKey)) { + $field_handlers = $this->view->getHandlers('field'); + $chart_settings = $this->options['chart_settings']; + $chart_fields = $chart_settings['fields']; + $label_field = $field_handlers[$chart_fields['label']] ?? ''; + $this->labelFieldKey = $label_field ? $chart_fields['label'] : ''; + } + + return $this->labelFieldKey; + } + + /** + * Helper method to build the chart data elements of a grouped chart. + * + * @param array $chart + * The main chart element. + * @param array $sets + * The grouped record set. + * @param array $data_fields + * The selected data fields on the chart style options. + */ + private function groupedChartElementBuild(array &$chart, array $sets, array $data_fields) { + $original_xaxis = $chart['xaxis']; + $xaxis_labels = []; + $label_field_key = $this->getLabelFieldKey(); + if (!empty($sets['_charts_xaxis_labels'])) { + $chart['xaxis']['#labels'] = $sets['_charts_xaxis_labels']; + unset($sets['_charts_xaxis_labels']); + // Flipping the labels here to get access to their index based on value. + $xaxis_labels = array_flip($chart['xaxis']['#labels']); + } + + $series_index = 0; + $element_key_prefix = $this->view->current_display . '__' . $label_field_key; + $chart_settings = $this->options['chart_settings']; + foreach ($sets as $set_label => $data_set) { + $name = strtolower(Html::cleanCssIdentifier('set-label-' . $set_label)); + // Remove the added prefix after processing. + $name = substr($name, strlen('set-label-')); + $element_key = $element_key_prefix . '__' . $name; + $chart[$element_key] = [ + '#type' => 'chart_data', + '#data' => $xaxis_labels ? array_fill(0, count($xaxis_labels), NULL) : [], + // If using a grouping field, inherit from the chart level colors. + '#title' => $set_label, + '#prefix' => $chart_settings['yaxis']['prefix'] ?? NULL, + '#suffix' => $chart_settings['yaxis']['suffix'] ?? NULL, + '#decimal_count' => $chart_settings['yaxis']['decimal_count'] ?? '', + ]; + if (!empty($data_set['color'])) { + $chart[$element_key]['#color'] = $data_set['color']; + } + + foreach ($data_set['rows'] as $result_number => $row) { + $set_id = $row->xaxis_label_index ?? $series_index; + foreach ($data_fields as $field_key => $field_handler) { + // Don't allow the grouping field to provide data. + if ($field_key === $this->options['grouping'][0]['field']) { + continue; + } + $value = $this->processNumberValueFromField($result_number, $field_key); + if (strpos($field_handler['id'], 'field_charts_fields_scatter') === 0 || strpos($field_handler['id'], 'field_charts_fields_bubble') === 0) { + $chart[$element_key]['#data'] = []; + $chart[$element_key]['#data'][] = $value; + $chart['xaxis'] = $original_xaxis; + } + else { + $chart[$element_key]['#data'][$set_id] = $value; + $chart[$element_key]['#mapped_data'][$set_id] = $value; + } + } + } + + // Incrementing series index. + $series_index++; + } + } + + /** + * Grouping chart settings ajax callback. + * + * @param array $form + * The form. + * @param \Drupal\core\form\FormStateInterface $form_state + * The form state. + * + * @return array + * The render array of the chart settings. + */ + public static function groupingChartSettingsAjaxCallback(array $form, FormStateInterface $form_state) { + return $form['options']['style_options']['chart_settings']; + } + + /** + * Returns the selected color. + * + * @param \Drupal\views\Plugin\views\field\EntityField $view_entity_field + * The view entity field. + * @param \Drupal\views\ResultRow $row + * The result row. + * @param string $group_field_name + * The grouping field name. + * + * @return string + * The color. + */ + private function extractGroupedSelectedColorByEntity(EntityField $view_entity_field, ResultRow $row, string $group_field_name) { + $chart_settings = $this->options['chart_settings']; + $colors_settings = $chart_settings['fields']['entity_grouping']['selected_method']['colors'] ?? []; + /** @var \Drupal\Core\Entity\ContentEntityInterface $host_entity */ + $host_entity = $view_entity_field->getEntity($row); + /** @var \Drupal\Core\Entity\ContentEntityInterface $referenced_entity */ + $referenced_entity = $host_entity->get($group_field_name)->entity; + if (!$referenced_entity || !$colors_settings) { + return ''; + } + + $entity_type = $referenced_entity->getEntityType(); + $has_uuid_key = $entity_type->hasKey('uuid'); + $color_id_key = $has_uuid_key ? $referenced_entity->get('uuid')->value : $referenced_entity->id(); + return $colors_settings[$color_id_key]['color'] ?? ''; + } + + /** + * Returns the color from the referenced entity field. + * + * @param \Drupal\views\Plugin\views\field\EntityField $view_entity_field + * The view entity field. + * @param \Drupal\views\ResultRow $row + * The result row. + * @param string $group_field_name + * The grouping field name. + * + * @return string + * The color. + */ + private function extractGroupedSelectedColorOnReferencedEntityField(EntityField $view_entity_field, ResultRow $row, string $group_field_name) { + $chart_settings = $this->options['chart_settings']; + $color_field_name = $chart_settings['fields']['entity_grouping']['selected_method']['color_field_name'] ?? ''; + if (!$color_field_name) { + return ''; + } + /** @var \Drupal\Core\Entity\ContentEntityInterface $host_entity */ + $host_entity = $view_entity_field->getEntity($row); + /** @var \Drupal\Core\Entity\ContentEntityInterface $referenced_entity */ + $referenced_entity = $host_entity->get($group_field_name)->entity; + $field_item_list = $referenced_entity ? $referenced_entity->get($color_field_name) : NULL; + if (!$field_item_list || $field_item_list->isEmpty()) { + return ''; + } + + /** @var \Drupal\color_field\Plugin\Field\FieldType\ColorFieldType $color_field */ + $color_field = $field_item_list->first(); + return $color_field->getFieldDefinition()->getType() === 'color_field_type' ? $color_field->color : ''; + } + + /** + * Ensures chart attachments are placed correctly on chart. + * + * @param array $parent_labels + * The parent labels. + * @param array $child_mapped_data + * The mapped data of the child display. + * @param array $data + * The data. + * + * @return array + * $processed_data + */ + private function alignSubchartData(array $parent_labels, array $child_mapped_data, array $data) { + $child_labels = array_keys($child_mapped_data); + if ($parent_labels === $child_labels) { + return $data; + } + $processed_data = []; + foreach ($parent_labels as $parent_label) { + $processed_data[] = $child_mapped_data[$parent_label] ?? NULL; + } + + return $processed_data; + } + + /** + * Returns the grouping colors for single axis chart type. + * + * @param array $data + * The data. + * + * @return array + * The grouping colors. + */ + private function extractGroupingColorsForSingleAxisChartType(array $data): array { + if (empty($this->options['grouping'][0]['field'])) { + return []; + } + + $grouping_colors = []; + $grouping_field = $this->options['grouping'][0]['field']; + $chart_settings = $this->options['chart_settings']; + $color_selection_method = $chart_settings['fields']['entity_grouping']['color_selection_method'] ?? ''; + $grouping_entity_field = $this->view->field[$grouping_field]; + $group_field_name = $grouping_entity_field ? ($grouping_entity_field->definition['field_name'] ?? '') : ''; + foreach ($data as $index => $set) { + $row = $this->view->result[$index]; + if ($color_selection_method && $group_field_name && $grouping_entity_field instanceof EntityField && $row instanceof ResultRow) { + switch ($color_selection_method) { + case 'by_entities_on_entity_reference': + $grouping_colors[$index][$set[0]] = $this->extractGroupedSelectedColorByEntity($grouping_entity_field, $row, $group_field_name); + break; + + case 'by_field_on_referenced_entity': + $grouping_colors[$index][$set[0]] = $this->extractGroupedSelectedColorOnReferencedEntityField($grouping_entity_field, $row, $group_field_name); + break; + } + } + } + return $grouping_colors; + } + +} diff --git a/web/modules/charts/src/PluginsUninstallValidator.php b/web/modules/charts/src/PluginsUninstallValidator.php new file mode 100644 index 00000000..90547a80 --- /dev/null +++ b/web/modules/charts/src/PluginsUninstallValidator.php @@ -0,0 +1,46 @@ +configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public function validate($module) { + $reasons = []; + $config = $this->configFactory->get('charts.settings'); + $dependent_modules = $config->get('dependencies.module') ?? []; + if (in_array($module, $dependent_modules)) { + $reasons[] = $this->t('Provides a chart library or chart type plugin configured as the default option for chart. Please update the configuration or reset them before uninstalling it.'); + } + return $reasons; + } + +} diff --git a/web/modules/charts/src/ProxyClass/PluginsUninstallValidator.php b/web/modules/charts/src/ProxyClass/PluginsUninstallValidator.php new file mode 100644 index 00000000..6f44c08c --- /dev/null +++ b/web/modules/charts/src/ProxyClass/PluginsUninstallValidator.php @@ -0,0 +1,88 @@ +container = $container; + $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id; + } + + /** + * Lazy loads the real service from the container. + * + * @return object + * Returns the constructed real service. + */ + protected function lazyLoadItself() + { + if (!isset($this->service)) { + $this->service = $this->container->get($this->drupalProxyOriginalServiceId); + } + + return $this->service; + } + + /** + * {@inheritdoc} + */ + public function validate($module) + { + return $this->lazyLoadItself()->validate($module); + } + + /** + * {@inheritdoc} + */ + public function setStringTranslation(\Drupal\Core\StringTranslation\TranslationInterface $translation) + { + return $this->lazyLoadItself()->setStringTranslation($translation); + } + + } + +} diff --git a/web/modules/charts/src/TypeManager.php b/web/modules/charts/src/TypeManager.php new file mode 100755 index 00000000..9588b77d --- /dev/null +++ b/web/modules/charts/src/TypeManager.php @@ -0,0 +1,104 @@ + '', + 'label' => '', + 'axis' => ChartInterface::DUAL_AXIS, + 'axis_inverted' => FALSE, + 'stacking' => FALSE, + 'class' => Type::class, + ]; + + /** + * The event dispatcher. + * + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + protected $eventDispatcher; + + /** + * Constructs a new TypeManager object. + * + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend + * The cache backend. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher + * Even dispatcher. + */ + public function __construct(ModuleHandlerInterface $module_handler, CacheBackendInterface $cache_backend, EventDispatcherInterface $event_dispatcher) { + $this->moduleHandler = $module_handler; + $this->eventDispatcher = $event_dispatcher; + $this->setCacheBackend($cache_backend, 'charts_type', ['charts_type']); + $this->alterInfo('charts_type_info'); + } + + /** + * {@inheritdoc} + */ + protected function getDiscovery() { + if (!isset($this->discovery)) { + $this->discovery = new YamlDiscovery('charts_types', $this->moduleHandler->getModuleDirectories()); + $this->discovery->addTranslatableProperty('label', 'label_context'); + $this->discovery = new ContainerDerivativeDiscoveryDecorator($this->discovery); + } + return $this->discovery; + } + + /** + * {@inheritdoc} + */ + public function processDefinition(&$definition, $plugin_id) { + parent::processDefinition($definition, $plugin_id); + + $definition['id'] = $plugin_id; + foreach (['label'] as $required_property) { + if (empty($definition[$required_property])) { + throw new PluginException(sprintf('The charts type %s must define the %s property.', $plugin_id, $required_property)); + } + } + } + + /** + * {@inheritdoc} + */ + public function getDefinitions() { + $definitions = parent::getDefinitions(); + + // Allow other modules to alter the definition list. + $event = new TypesInfoEvent($definitions); + $this->eventDispatcher->dispatch($event, ChartsEvents::TYPE_INFO); + $definitions = $event->getTypes(); + + return $definitions; + } + +} diff --git a/web/modules/charts/src/Util/Util.php b/web/modules/charts/src/Util/Util.php new file mode 100755 index 00000000..29c66e91 --- /dev/null +++ b/web/modules/charts/src/Util/Util.php @@ -0,0 +1,151 @@ +getStyle()->options['chart_settings']; + foreach ($view->result as $row_number => $row) { + $view->row_index = $row->index; + $numberFields = 0; + $rowData = []; + foreach ($labelValues as $fieldId => $rowDataValue) { + if ($style_options['fields']['allow_advanced_rendering'] == 1 || isset($view->field[$labelField]->options['type']) && $view->field[$labelField]->options['type'] === 'timestamp') { + $renderedLabelField = $view->field[$labelField]->advancedRender($row); + } + else { + $renderedLabelField = $view->field[$labelField]->getValue($row); + } + $renderedLabelField = strip_tags($renderedLabelField); + $rowData[$numberFields] = [ + 'value' => $style_options['fields']['allow_advanced_rendering'] ? $view->field[$fieldId]->advancedRender($row) : $view->field[$fieldId]->getValue($row), + 'label_field' => $renderedLabelField, + 'label' => $view->field[$fieldId]->label(), + 'color' => $color[$fieldId]['color'], + 'type' => $attachmentChartTypeOption, + ]; + $numberFields++; + } + $data[$row_number] = $rowData; + } + + return $data; + } + + /** + * Removes unselected fields. + * + * @param array $valueField + * Value Field. + * + * @return array + * Field Values. + */ + public static function removeUnselectedFields(array $valueField = []) { + $fieldValues = []; + foreach ($valueField as $key => $value) { + if (!empty($value)) { + $fieldValues[$key] = $value; + } + } + return $fieldValues; + } + + /** + * Remove hidden fields. + * + * @param \Drupal\views\ViewExecutable $view + * The view. + * @param array $fieldValues + * Field values. + * + * @return array + * Visible views. + */ + public static function removeHiddenFields(ViewExecutable $view, array $fieldValues) { + $fields = $view->display_handler->getOption('fields'); + $visibleFields = array_filter($fields, function ($field) { + return !empty($field['exclude']); + }); + $visibleFields = array_diff_key($fieldValues, $visibleFields); + + return $visibleFields; + } + + /** + * Creates chart data to be used later by visualization frameworks. + * + * @param array $data + * Data. + * + * @return array + * Chart Data. + */ + public static function createChartableData(array $data = []) { + $chartData = []; + $categories = []; + $seriesData = []; + + for ($i = 0; $i < count($data[0]); $i++) { + + $seriesRowData = [ + 'name' => '', + 'color' => '', + 'type' => '', + 'data' => [], + ]; + for ($j = 0; $j < count($data); $j++) { + $categories[$j] = $data[$j][$i]['label_field']; + $seriesRowData['name'] = $data[$j][$i]['label']; + $seriesRowData['type'] = $data[$j][$i]['type']; + $seriesRowData['color'] = $data[$j][$i]['color']; + array_push($seriesRowData['data'], (json_decode(($data[$j][$i]['value'])))); + } + array_push($seriesData, $seriesRowData); + } + $chartData[0] = $categories; + $chartData[1] = $seriesData; + + return $chartData; + } + + /** + * Checks for missing libraries necessary for data visualization. + * + * @param string $libraryPath + * Library Path. + */ + public static function checkMissingLibrary($libraryPath = '') { + if (!file_exists(DRUPAL_ROOT . DIRECTORY_SEPARATOR . $libraryPath)) { + \Drupal::service('messenger') + ->addMessage(t('Charting libraries might not be installed at the location @libraryPath.', [ + '@libraryPath' => $libraryPath, + ]), 'error'); + } + } + +} diff --git a/web/modules/charts/templates/charts-chart.html.twig b/web/modules/charts/templates/charts-chart.html.twig new file mode 100755 index 00000000..3a30a04b --- /dev/null +++ b/web/modules/charts/templates/charts-chart.html.twig @@ -0,0 +1,4 @@ +{{ content_prefix }} +{{ content }} +{{ debug }} +{{ content_suffix }} diff --git a/web/modules/charts/tests/modules/charts_test/charts_test.info.yml b/web/modules/charts/tests/modules/charts_test/charts_test.info.yml new file mode 100644 index 00000000..c8e6a337 --- /dev/null +++ b/web/modules/charts/tests/modules/charts_test/charts_test.info.yml @@ -0,0 +1,12 @@ +name: Charts test +type: module +description: 'Provides functionality for testing the charts module.' +package: Testing +core_version_requirement: ^8.8 || ^9 || ^10 || ^11 +dependencies: + - charts:charts + +# Information added by Drupal.org packaging script on 2024-12-02 +version: '5.1.1' +project: 'charts' +datestamp: 1733170633 diff --git a/web/modules/charts/tests/modules/charts_test/charts_test.routing.yml b/web/modules/charts/tests/modules/charts_test/charts_test.routing.yml new file mode 100755 index 00000000..539a0b37 --- /dev/null +++ b/web/modules/charts/tests/modules/charts_test/charts_test.routing.yml @@ -0,0 +1,8 @@ +charts_test.data_collector_table_test_form: + path: '/charts_test/data_collector_table_test_form' + defaults: + _form: '\Drupal\charts_test\Form\DataCollectorTableTestForm' + _title: 'Data collector table test form' + requirements: + # Needs to be accessed by an anonymous user. + _access: 'TRUE' diff --git a/web/modules/charts/tests/modules/charts_test/config/schema/charts_test.schema.yml b/web/modules/charts/tests/modules/charts_test/config/schema/charts_test.schema.yml new file mode 100644 index 00000000..ab25afb6 --- /dev/null +++ b/web/modules/charts/tests/modules/charts_test/config/schema/charts_test.schema.yml @@ -0,0 +1,6 @@ +charts.settings.library_plugin.charts_test_library: + type: mapping + mapping: + foo: + label: Foo + type: string diff --git a/web/modules/charts/tests/modules/charts_test/src/Form/DataCollectorTableTestForm.php b/web/modules/charts/tests/modules/charts_test/src/Form/DataCollectorTableTestForm.php new file mode 100755 index 00000000..6b0fe794 --- /dev/null +++ b/web/modules/charts/tests/modules/charts_test/src/Form/DataCollectorTableTestForm.php @@ -0,0 +1,52 @@ +config('charts.settings'); + $form['series'] = [ + '#type' => 'chart_data_collector_table', + '#initial_rows' => self::INITIAL_ROWS, + '#initial_columns' => self::INITIAL_COLUMNS, + '#table_drag' => FALSE, + '#default_colors' => $config->get('charts_default_settings.display.colors') ?? [], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // {"name":"Number of players","color":"#0d233a","data": + // [50,60,100,132,133,234]},{"name":"Number of coaches","color":. + // "#ff0000","data": + // [50,80,100,32,133,234]}. + // A,b,c,d,e,f + } + +} diff --git a/web/modules/charts/tests/modules/charts_test/src/Plugin/chart/Library/ChartsTestLibrary.php b/web/modules/charts/tests/modules/charts_test/src/Plugin/chart/Library/ChartsTestLibrary.php new file mode 100755 index 00000000..66ca5576 --- /dev/null +++ b/web/modules/charts/tests/modules/charts_test/src/Plugin/chart/Library/ChartsTestLibrary.php @@ -0,0 +1,166 @@ +elementInfo = $element_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('element_info') + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'foo' => 'bar', + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function preRender(array $element) { + // Populate chart settings. + $chart_definition = []; + + $chart_definition = $this->populateOptions($element, $chart_definition); + $chart_definition = $this->populateData($element, $chart_definition); + + if (!empty($element['#height']) || !empty($element['#width'])) { + $element['#attributes']['style'] = 'height:' . $element['#height'] . $element['#height_units'] . ';width:' . $element['#width'] . $element['#width_units'] . ';'; + } + + if (!isset($element['#id'])) { + $element['#id'] = Html::getUniqueId('charts-charts-test-library-chart'); + } + + $element['#attributes']['class'][] = 'charts-charts-test-library-chart-container'; + $element['#chart_definition'] = $chart_definition; + + return $element; + } + + /** + * Populate options. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateOptions(array $element, array $chart_definition) { + $chart_definition['title']['text'] = $element['#title']; + $chart_definition['title']['color'] = $element['#title_color']; + $chart_definition['title']['position'] = $element['#title_position']; + $chart_definition['subtitle']['text'] = $element['#subtitle']; + $chart_definition['type'] = $element['#chart_type']; + $chart_definition['title']['font'] = [ + 'weight' => $element['#title_font_weight'], + 'style' => $element['#title_font_style'], + 'size' => $element['#title_font_size'], + ]; + $chart_definition['colors'] = $element['#colors']; + $chart_definition['tooltips'] = $element['#tooltips']; + $chart_definition['foo_configuration'] = $this->configuration['foo'] ?? ''; + + // Merge in chart raw options. + if (!empty($element['#raw_options'])) { + $chart_definition = NestedArray::mergeDeep($chart_definition, $element['#raw_options']); + } + + return $chart_definition; + } + + /** + * Populate data. + * + * @param array $element + * The element. + * @param array $chart_definition + * The chart definition. + * + * @return array + * Return the chart definition. + */ + private function populateData(array &$element, array $chart_definition) { + $chart_definition['series'] = []; + foreach (Element::children($element) as $key) { + $type = $element[$key]['#type']; + if ($type !== 'chart_data') { + continue; + } + + $chart_definition['series'][] = [ + 'name' => $element[$key]['#title'], + 'color' => $element[$key]['#color'], + 'value' => $element[$key]['#data'], + ]; + } + + return $chart_definition; + } + +} diff --git a/web/modules/charts/tests/modules/charts_test/test_views/views.view.test_charts.yml b/web/modules/charts/tests/modules/charts_test/test_views/views.view.test_charts.yml new file mode 100644 index 00000000..fc59bd8e --- /dev/null +++ b/web/modules/charts/tests/modules/charts_test/test_views/views.view.test_charts.yml @@ -0,0 +1,144 @@ +langcode: en +status: true +dependencies: { } +id: test_charts +label: '' +module: views +description: '' +tag: '' +base_table: views_test_data +base_field: nid +display: + default: + id: default + display_title: Default + display_plugin: default + position: 0 + display_options: + title: 'Test charts style' + defaults: + fields: false + pager: false + sorts: false + fields: + age: + id: age + field: age + relationship: none + table: views_test_data + plugin_id: numeric + id: + id: id + field: id + relationship: none + table: views_test_data + plugin_id: numeric + name: + id: name + field: name + relationship: none + table: views_test_data + plugin_id: string + job: + id: job + field: job + relationship: none + table: views_test_data + plugin_id: string + pager: + type: none + options: + offset: 0 + empty: + area_text_custom: + id: area_text_custom + table: views + field: area_text_custom + relationship: none + group_type: group + admin_label: '' + plugin_id: text_custom + empty: true + content: 'custom text for charts' + tokenize: false + sorts: + id: + field: id + id: id + order: ASC + relationship: none + table: views_test_data + plugin_id: numeric + style: + type: chart + options: + grouping: { } + chart_settings: + library: charts_test_library + type: column + fields: + label: name + stacking: false + data_providers: + age: + enabled: true + color: '#ff9300' + weight: 4 + id: + enabled: false + color: '#72fcd5' + weight: 4 + name: + enabled: false + color: '#d783ff' + weight: 4 + job: + enabled: false + color: '#b76b1f' + weight: 4 + display: + title: 'Test charts' + title_position: out + subtitle: '' + data_labels: true + data_markers: true + legend_position: bottom + background: '' + three_dimensional: 0 + polar: 0 + tooltips: true + dimensions: + width: '' + width_units: '' + height: '' + height_units: '' + gauge: + max: '' + min: '' + green_from: '' + green_to: '' + yellow_from: '' + yellow_to: '' + red_from: '' + red_to: '' + color_changer: false + xaxis: + title: Date + labels_rotation: '0' + yaxis: + title: Downloads + min: '' + max: '' + prefix: '' + suffix: '' + decimal_count: '' + labels_rotation: '0' + row: + type: fields + page_1: + id: page_1 + display_title: 'Page display' + display_plugin: page + position: 1 + display_options: + path: test-charts diff --git a/web/modules/charts/tests/resources/csv/first_column.csv b/web/modules/charts/tests/resources/csv/first_column.csv new file mode 100755 index 00000000..24dc7da3 --- /dev/null +++ b/web/modules/charts/tests/resources/csv/first_column.csv @@ -0,0 +1,7 @@ +Categories,Number of players,Number of coaches +a,50,50 +b,60,80 +c,100,100 +d,132,32 +e,133,133 +f,234,234 diff --git a/web/modules/charts/tests/resources/csv/first_row.csv b/web/modules/charts/tests/resources/csv/first_row.csv new file mode 100755 index 00000000..a85af08d --- /dev/null +++ b/web/modules/charts/tests/resources/csv/first_row.csv @@ -0,0 +1,2 @@ +Categories,a,b +Label,200,400 diff --git a/web/modules/charts/tests/src/Functional/Plugin/StyleChartsTest.php b/web/modules/charts/tests/src/Functional/Plugin/StyleChartsTest.php new file mode 100644 index 00000000..418ea758 --- /dev/null +++ b/web/modules/charts/tests/src/Functional/Plugin/StyleChartsTest.php @@ -0,0 +1,59 @@ +enableViewsTestModule(); + } + + /** + * Tests the generated JSON generated from the views. + */ + public function testGeneratedJson() { + $this->drupalGet('test-charts'); + + $this->assertSession()->statusCodeEquals(Response::HTTP_OK); + + $attribute = 'data-chart'; + $chart_container = $this->assertSession()->elementAttributeExists('css', '#chart-test-charts-page-1', $attribute); + + $actual = str_replace('"', '"', (string) $chart_container->getAttribute($attribute)); + $expected = '{"title":{"text":"Test charts","color":"#000","position":"out","font":{"weight":"normal","style":"normal","size":14}},"subtitle":{"text":""},"type":"column","colors":["#006fb0","#f07c33","#342e9c","#579b17","#3f067a","#cbde67","#7643b6","#738d00","#c157c7","#02dab1","#ed56b4","#d8d981","#004695","#736000","#a5a5ff","#833a00","#ff9ee9","#684507","#fe4f85","#5d0011","#ffa67b","#88005c","#ff9b8f","#85000f","#ff7581"],"tooltips":true,"foo_configuration":"","series":[]}'; + $this->assertEquals($expected, $actual); + } + +} diff --git a/web/modules/charts/tests/src/FunctionalJavascript/ConfigOverrideTest.php b/web/modules/charts/tests/src/FunctionalJavascript/ConfigOverrideTest.php new file mode 100644 index 00000000..7c6e9563 --- /dev/null +++ b/web/modules/charts/tests/src/FunctionalJavascript/ConfigOverrideTest.php @@ -0,0 +1,48 @@ +drupalLogin($this->rootUser); + + // Set up an override of the cdn. + $settings['config']['charts.settings']['advanced']['requirements']['cdn'] = (object) [ + 'value' => FALSE, + 'required' => TRUE, + ]; + $this->writeSettings($settings); + + // Check the error message. + $this->drupalGet('admin/reports/status'); + $this->assertSession()->pageTextContains('You are missing the Chart.js library in your Drupal installation directory and you have opted not to use a CDN.'); + } + +} diff --git a/web/modules/charts/tests/src/FunctionalJavascript/DataCollectorTableTest.php b/web/modules/charts/tests/src/FunctionalJavascript/DataCollectorTableTest.php new file mode 100755 index 00000000..464bf7ff --- /dev/null +++ b/web/modules/charts/tests/src/FunctionalJavascript/DataCollectorTableTest.php @@ -0,0 +1,289 @@ +updateFooConfiguration('bar'); + } + + /** + * Tests the data collector table. + */ + public function testDataCollectorTable() { + $this->drupalGet('/charts_test/data_collector_table_test_form'); + $table_id_selector = static::TABLE_SELECTOR; + + // Count generated rows. + $rows = $this->cssSelect(static::TABLE_ROW_SELECTOR); + $this->assertTrue(count($rows) === DataCollectorTableTestForm::INITIAL_ROWS, 'Expected rows were found.'); + + // Count generated columns. + // Only using the first row to count columns. + $columns = $this->cssSelect(static::TABLE_COLUMN_SELECTOR); + $this->assertTrue(count($columns) === DataCollectorTableTestForm::INITIAL_COLUMNS, 'Expected columns were found.'); + + // Fill the table. + $cell_input_selector = $table_id_selector . ' tr.data-collector-table--row td > .data-collector-table--row--cell .form-text'; + $cell_inputs = $this->cssSelect($cell_input_selector); + $this->assertNotEmpty($cell_inputs, $this->t('@count inputs were found', [ + '@count' => count($cell_inputs), + ])); + $this->fillInputs($cell_inputs); + + // Test adding a row. + $this->doTableOperation('add', 'row'); + // The New row should not have any data. + $this->assertNewCellIsEmpty('row'); + + // Test adding a column. + $this->doTableOperation('add', 'column'); + // The New column should not have any data. + $this->assertNewCellIsEmpty('column'); + + // Delete the second and third row. + $this->doTableOperation('delete', 'row', [2, 3]); + // Remove the middle column. + $this->doTableOperation('delete', 'column', [2]); + + // Import csv from resources. + // Opening the dom "details" element. + $series_import_details = $this->getSession()->getPage()->find('css', 'details[data-drupal-selector="edit-series-import"]'); + $this->assertFalse($series_import_details->hasAttribute('open'), 'Details closed'); + $this->getSession()->executeScript("document.querySelector('details[data-drupal-selector=edit-series-import]').setAttribute('open', true);"); + $this->assertTrue($series_import_details->hasAttribute('open'), 'Details open'); + // Add the CSV file containing the test data. + $file_field_name = 'files[series]'; + $filename = $this->getResourcePath() . '/csv/first_column.csv'; + $this->getSession()->getPage()->attachFileToField($file_field_name, $filename); + // Submit upload the file and wait for ajax processing. + $button_selector = 'details[data-drupal-selector="edit-series-import"] input[value="Upload CSV"]'; + $this->pressAjaxButton($button_selector); + $this->assertSession()->assertWaitOnAjaxRequest(20000); + + // Verify the uploaded data. + $page = $this->getSession()->getPage(); + $targets = $page->findAll('css', static::TABLE_ROW_SELECTOR); + $this->assertEquals(7, count($targets), 'The count of number of expected rows match.'); + $targets = $page->findAll('css', static::TABLE_COLUMN_SELECTOR); + $this->assertEquals(3, count($targets), 'The count of number of expected columns match.'); + $cell_input = $page->find('css', 'input[name="series[data_collector_table][0][0][data]"]'); + $this->assertEquals('Categories', $cell_input->getValue(), 'The cell value of row 1 at column 1 match.'); + $cell_input = $page->find('css', 'input[name="series[data_collector_table][6][2][data]"]'); + $this->assertEquals(234, $cell_input->getValue(), 'The last uploaded value in the cell match.'); + } + + /** + * Tests the default colors pn the "chart_data_collector_table" element. + */ + public function testColorDefaultColors() { + $chart_config = \Drupal::config('charts.settings'); + $default_colors = $chart_config->get('charts_default_settings.display.colors'); + $this->drupalGet('/charts_test/data_collector_table_test_form'); + $page = $this->getSession()->getPage(); + + // Get the first row, then inside the first row get the color input and + // check its value. + $first_row_color_input = $page->find('css', static::TABLE_ROW_SELECTOR . ':first-child td:nth-child(2) input[type="color"]'); + $this->assertEquals($default_colors[0], $first_row_color_input->getValue()); + + // Adding one column. + $this->doTableOperation('add', 'column'); + + // Checking if the added column also has the expected color. + $first_row_color_input = $page->find('css', static::TABLE_ROW_SELECTOR . ':first-child td:nth-child(3) input[type="color"]'); + $this->assertEquals($default_colors[1], $first_row_color_input->getValue()); + } + + /** + * Do table operation. + * + * @param string $operation + * The operation. + * @param string $on + * The element. + * @param array $positions + * The position. + */ + protected function doTableOperation(string $operation, string $on, array $positions = []) { + $value = ucfirst($operation) . ' ' . $on; + + if ($operation === 'add') { + $selector = static::TABLE_SELECTOR . ' input[value="' . $value . '"]'; + $this->pressAjaxButton($selector); + + if ($on === 'row') { + $this->assertRowsIncreased(); + } + else { + $this->assertColumnsIncreased(); + } + } + else { + $on_row = $on === 'row'; + if ($on_row) { + $counter = DataCollectorTableTestForm::INITIAL_ROWS + 1; + $locator = static::TABLE_ROW_SELECTOR; + } + else { + $counter = DataCollectorTableTestForm::INITIAL_COLUMNS + 1; + $locator = static::TABLE_COLUMN_SELECTOR; + } + foreach ($positions as $position) { + if ($on_row) { + $button_selector = static::TABLE_ROW_SELECTOR . ':nth-child(' . $position . ') .data-collector-table--row--delete input[value="Delete row"]'; + } + else { + $button_selector = static::TABLE_SELECTOR . ' .data-collector-table--column-deletes-row'; + $button_selector .= ' .data-collector-table--column--delete:nth-child(' . $position . ') input[value="Delete column"]'; + } + $this->pressAjaxButton($button_selector); + $this->assertDeletionOperation($counter, $locator); + } + } + } + + /** + * Assert rows. + */ + protected function assertRowsIncreased() { + $page = $this->getSession()->getPage(); + $this->assertTrue($page->waitFor(10, function ($page) { + $expected_rows = DataCollectorTableTestForm::INITIAL_ROWS + 1; + $rows = $page->findAll('css', static::TABLE_ROW_SELECTOR); + return count($rows) === $expected_rows; + }), 'Expected rows were increased by one after add row click.'); + } + + /** + * Assert columns. + */ + protected function assertColumnsIncreased() { + $page = $this->getSession()->getPage(); + $this->assertTrue($page->waitFor(10, function ($page) { + $expected_rows = DataCollectorTableTestForm::INITIAL_COLUMNS + 1; + $columns = $page->findAll('css', static::TABLE_COLUMN_SELECTOR); + return count($columns) === $expected_rows; + }), 'Expected columns were increased by one after add column click.'); + } + + /** + * Assert new cell. + * + * @param string $on + * The element. + */ + protected function assertNewCellIsEmpty(string $on) { + $page = $this->getSession()->getPage(); + if ($on === 'row') { + $counter = DataCollectorTableTestForm::INITIAL_ROWS + 1; + $selector = static::TABLE_ROW_SELECTOR . ':nth-child(' . $counter . ') td:first-child input'; + } + else { + $counter = DataCollectorTableTestForm::INITIAL_COLUMNS + 1; + $selector = static::TABLE_COLUMN_SELECTOR . ':nth-child(' . $counter . ') input'; + } + $cell_input = $page->find('css', $selector); + $this->assertEmpty($cell_input->getValue(), 'Added row cells are empty.'); + } + + /** + * Assert Deletion operation. + * + * @param int $current_count + * Current count. + * @param string $locator + * The locator. + */ + protected function assertDeletionOperation(int &$current_count, string $locator) { + $web_assert = $this->assertSession(); + $web_assert->assertWaitOnAjaxRequest(); + $current_count--; + $page = $this->getSession()->getPage(); + $targets = $page->findAll('css', $locator); + $this->assertTrue(count($targets) === $current_count); + } + + /** + * Press the ajax button. + * + * @param string $selector + * The selector. + */ + protected function pressAjaxButton(string $selector) { + $button = $this->assertSession()->waitForElementVisible('css', $selector); + $this->assertNotEmpty($button); + $button->click(); + } + + /** + * Fill inputs. + * + * @param array $inputs + * Input to fill. + * + * @return \Behat\Mink\Element\NodeElement[] + * The node element. + */ + protected function fillInputs(array $inputs) { + /** @var \Behat\Mink\Element\NodeElement[] $inputs */ + foreach ($inputs as $input) { + $value = rand(0, count($inputs)); + $input->setValue((string) $value); + } + return $inputs; + } + + /** + * Get the path of the resource. + * + * @return string + * The resource folder path. + */ + protected function getResourcePath() { + return \Drupal::root() . '/' . \Drupal::service('extension.list.module')->getPath('charts') . '/tests/resources'; + } + +} diff --git a/web/modules/charts/tests/src/Kernel/ChartElementTest.php b/web/modules/charts/tests/src/Kernel/ChartElementTest.php new file mode 100644 index 00000000..699fa981 --- /dev/null +++ b/web/modules/charts/tests/src/Kernel/ChartElementTest.php @@ -0,0 +1,53 @@ +updateFooConfiguration('bar'); + } + + /** + * Tests the chart test element. + */ + public function testChartElement() { + $element = [ + '#type' => 'chart', + '#library' => 'charts_test_library', + '#chart_type' => 'column', + ]; + + $json = "{'title':{'text':null,'color':'#000','position':'out','font':{'weight':'normal','style':'normal','size':14}},'subtitle':{'text':null},'type':'column','colors':['#2f7ed8','#0d233a','#8bbc21','#910000','#1aadce','#492970','#f28f43','#77a1e5','#c42525','#a6c96a'],'tooltips':true,'foo_configuration':'bar','series':[]}"; + $this->assertElementJson($element, $json); + + // Testing raw options. + $element['#raw_options'] = ['title' => ['text' => 'Foo']]; + $path = ['title', 'text']; + $this->assertJsonPropertyHasValue($element, $path, 'Foo'); + $this->assertJsonPropertyHasValue($element, ['type'], 'column'); + } + +} diff --git a/web/modules/charts/tests/src/Kernel/ChartLibraryConfigTest.php b/web/modules/charts/tests/src/Kernel/ChartLibraryConfigTest.php new file mode 100644 index 00000000..7c0e7a28 --- /dev/null +++ b/web/modules/charts/tests/src/Kernel/ChartLibraryConfigTest.php @@ -0,0 +1,55 @@ +updateFooConfiguration('bar'); + } + + /** + * Tests if library config is available after rendering. + */ + public function testLibraryConfigIsAvailable() { + $element = [ + '#type' => 'chart', + '#library' => 'charts_test_library', + '#chart_type' => 'column', + ]; + + $path = ['foo_configuration']; + // Confirm that 'foo_configuration' option is set to 'bar'. + $this->assertJsonPropertyHasValue($element, $path, 'bar'); + + // Check if we change the default config for the library config it will + // pick up after rendering. + $new_value = $this->randomString(); + $this->updateFooConfiguration($new_value); + $this->assertJsonPropertyHasValue($element, $path, $new_value); + } + +} diff --git a/web/modules/charts/tests/src/Kernel/ChartTypeSupportTest.php b/web/modules/charts/tests/src/Kernel/ChartTypeSupportTest.php new file mode 100644 index 00000000..33fce760 --- /dev/null +++ b/web/modules/charts/tests/src/Kernel/ChartTypeSupportTest.php @@ -0,0 +1,83 @@ +updateFooConfiguration('bar'); + } + + /** + * Tests the chart type support functionality. + * + * @param string $chart_type + * The chart type to test. + * @param string $expected_exception + * The expected exception. + * @param string $expected_exception_message + * The expected exception message. + * + * @dataProvider provideChartTypesData + */ + public function testNotSupportedChartType(string $chart_type, string $expected_exception, string $expected_exception_message) { + $element = [ + '#type' => 'chart', + '#library' => 'charts_test_library', + '#chart_type' => $chart_type, + ]; + + $this->expectException($expected_exception); + $this->expectExceptionMessage($expected_exception_message); + $this->renderer->renderRoot($element); + } + + /** + * Provides data for chart types support test. + * + * @return array[] + * The chart types test cases. + */ + public function provideChartTypesData(): array { + return [ + // Asserting that a chart type that is not a valid plugin chart type will + // throw a plugin not found exception during discovery. + 'chart type plugin does not exist' => [ + 'not_supported', + PluginNotFoundException::class, + 'The "not_supported" plugin does not exist. Valid plugin IDs for Drupal\charts\TypeManager are: area, bar, bubble, column, donut, gauge, line, pie, scatter, spline', + ], + // Asserting that a chart type not supported by a chart library plugin + // will throw a logic exception. + 'chart type plugin exists but not supported by the chart library plugin' => [ + 'spline', + \LogicException::class, + 'The provided chart type "spline" is not supported by "Charts Test Library" chart plugin library.', + ], + ]; + } + +} diff --git a/web/modules/charts/tests/src/Kernel/ChartsKernelTestBase.php b/web/modules/charts/tests/src/Kernel/ChartsKernelTestBase.php new file mode 100644 index 00000000..41a0bd57 --- /dev/null +++ b/web/modules/charts/tests/src/Kernel/ChartsKernelTestBase.php @@ -0,0 +1,110 @@ +renderer = $this->container->get('renderer'); + } + + /** + * Asserts that the given element returned the expected json. + * + * @param array $element + * The element to check. + * @param string $expected_json + * The expected json. + */ + public function assertElementJson(array $element, string $expected_json) { + $this->assertEquals($expected_json, $this->getDataChartJson($element)); + } + + /** + * Asserts that json property has value by given element. + * + * @param array $element + * The element structure. + * @param array $parents + * The parent structure where the value is. + * @param string $expected_value + * The expected value. + */ + public function assertJsonPropertyHasValue(array $element, array $parents, string $expected_value) { + $json = $this->getDataChartJson($element); + $decoded_json = json_decode(str_replace("'", '"', $json), TRUE); + $actual_value = NestedArray::getValue($decoded_json, $parents); + $this->assertEquals($expected_value, $actual_value); + } + + /** + * Get the json data. + * + * @param array $element + * The element to be rendered. + * + * @return string + * The string with the data from the chart. + */ + private function getDataChartJson(array $element): string { + $renderer_output = $this->renderer->renderRoot($element); + $crawler = new Crawler(); + $crawler->addHtmlContent(str_replace('"', "'", $renderer_output)); + return $crawler->filter('[data-chart]')->attr('data-chart'); + } + + /** + * Get the chart style from the element. + * + * @param array $element + * The element to be tested. + * @param string $filter_selector + * The selector to use for crawler filter. + * + * @return array + * The styles as an array. + */ + protected function getChartStyle(array $element, string $filter_selector = '[data-chart]'): array { + $renderer_output = $this->renderer->renderRoot($element); + $crawler = new Crawler(); + $crawler->addHtmlContent(str_replace('"', "'", $renderer_output)); + if (!$crawler->filter($filter_selector)->count()) { + return []; + } + + $style_attribute = $crawler->filter($filter_selector)->attr('style'); + // Explode the style attribute, filtering out the last (empty) element. + $styles = $style_attribute ? explode(';', rtrim($style_attribute, ';')) : []; + + // Convert the string into an array of key/value pairs. + $parsed_styles = []; + foreach ($styles as $style) { + if (strlen(trim($style)) > 0) { + [$property, $value] = explode(':', trim($style)); + $parsed_styles[$property] = $value; + } + } + + return $parsed_styles; + } + +} diff --git a/web/modules/charts/tests/src/Kernel/DimensionsTest.php b/web/modules/charts/tests/src/Kernel/DimensionsTest.php new file mode 100644 index 00000000..d289d615 --- /dev/null +++ b/web/modules/charts/tests/src/Kernel/DimensionsTest.php @@ -0,0 +1,72 @@ +getChartStyle($element); + $width = $styles['width'] ?? NULL; + $height = $styles['height'] ?? NULL; + $this->assertEquals($expected_width, $width); + $this->assertEquals($expected_height, $height); + } + + /** + * Provides chart elements for the test dimensions test. + * + * @return array[] + * the chart elements. + */ + public function provideChartElements(): array { + // A simple chart element with no dimensions set. + $element = [ + '#type' => 'chart', + '#chart_type' => 'column', + 'series' => [ + '#type' => 'chart_data', + '#title' => '5.0.x', + '#data' => [257, 235, 325, 340], + '#color' => '#1d84c3', + ], + ]; + // Element with the dimensions set. + $element_filled_dimensions = $element + [ + '#width' => 50, + '#width_units' => '%', + '#height' => 225, + '#height_units' => 'px', + ]; + + return [ + 'empty dimensions' => [$element, NULL, NULL], + 'filled dimensions' => [$element_filled_dimensions, '50%', '225px'], + ]; + } + +} diff --git a/web/modules/charts/tests/src/Traits/ConfigUpdateTrait.php b/web/modules/charts/tests/src/Traits/ConfigUpdateTrait.php new file mode 100644 index 00000000..aedea645 --- /dev/null +++ b/web/modules/charts/tests/src/Traits/ConfigUpdateTrait.php @@ -0,0 +1,27 @@ +config('charts.settings'); + $settings = $config->get('charts_default_settings'); + $settings['library'] = $library; + $settings['library_config'] = ['foo' => $value]; + $config->set('charts_default_settings', $settings); + $config->save(); + } + +} diff --git a/web/modules/views_simple_math_field/.gitlab-ci.yml b/web/modules/views_simple_math_field/.gitlab-ci.yml new file mode 100644 index 00000000..98646889 --- /dev/null +++ b/web/modules/views_simple_math_field/.gitlab-ci.yml @@ -0,0 +1,99 @@ +################ +# DrupalCI GitLabCI template +# +# Gitlab-ci.yml to replicate DrupalCI testing for Contrib +# +# With thanks to: +# * The GitLab Acceleration Initiative participants +# * DrupalSpoons +################ + +################ +# Guidelines +# +# This template is designed to give any Contrib maintainer everything they need to test, without requiring modification. It is also designed to keep up to date with Core Development automatically through the use of include files that can be centrally maintained. +# +# However, you can modify this template if you have additional needs for your project. +################ + +################ +# Includes +# +# Additional configuration can be provided through includes. +# One advantage of include files is that if they are updated upstream, the changes affect all pipelines using that include. +# +# Includes can be overridden by re-declaring anything provided in an include, here in gitlab-ci.yml +# https://docs.gitlab.com/ee/ci/yaml/includes.html#override-included-configuration-values +################ + +include: + ################ + # DrupalCI includes: + # As long as you include this, any future includes added by the Drupal Association will be accessible to your pipelines automatically. + # View these include files at https://git.drupalcode.org/project/gitlab_templates/ + ################ + - project: $_GITLAB_TEMPLATES_REPO + ref: $_GITLAB_TEMPLATES_REF + file: + - '/includes/include.drupalci.main.yml' + # EXPERIMENTAL: For Drupal 7, remove the above line and uncomment the below. + # - '/includes/include.drupalci.main-d7.yml' + - '/includes/include.drupalci.variables.yml' + - '/includes/include.drupalci.workflows.yml' + +################ +# Pipeline configuration variables +# +# These are the variables provided to the Run Pipeline form that a user may want to override. +# +# Docs at https://git.drupalcode.org/project/gitlab_templates/-/blob/1.0.x/includes/include.drupalci.variables.yml +################ +# variables: +# SKIP_ESLINT: '1' + + +################################################################################### +# +# * +# /( +# ((((, +# /((((((( +# ((((((((((* +# ,((((((((((((((( +# ,((((((((((((((((((( +# ((((((((((((((((((((((((* +# *((((((((((((((((((((((((((((( +# ((((((((((((((((((((((((((((((((((* +# *(((((((((((((((((( .(((((((((((((((((( +# ((((((((((((((((((. /(((((((((((((((((* +# /((((((((((((((((( .(((((((((((((((((, +# ,(((((((((((((((((( (((((((((((((((((( +# .(((((((((((((((((((( .((((((((((((((((( +# ((((((((((((((((((((((( ((((((((((((((((/ +# (((((((((((((((((((((((((((/ ,(((((((((((((((* +# .((((((((((((((/ /(((((((((((((. ,((((((((((((((( +# *(((((((((((((( ,(((((((((((((/ *((((((((((((((. +# ((((((((((((((, /(((((((((((((. ((((((((((((((, +# (((((((((((((/ ,(((((((((((((* ,(((((((((((((, +# *((((((((((((( .((((((((((((((( ,((((((((((((( +# ((((((((((((/ /((((((((((((((((((. ,((((((((((((/ +# ((((((((((((( *(((((((((((((((((((((((* *(((((((((((( +# ((((((((((((( ,(((((((((((((..((((((((((((( *(((((((((((( +# ((((((((((((, /((((((((((((* /((((((((((((/ (((((((((((( +# ((((((((((((( /((((((((((((/ (((((((((((((* (((((((((((( +# (((((((((((((/ /(((((((((((( ,((((((((((((, *(((((((((((( +# (((((((((((((( *(((((((((((/ *((((((((((((. ((((((((((((/ +# *((((((((((((((((((((((((((, /((((((((((((((((((((((((( +# ((((((((((((((((((((((((( ((((((((((((((((((((((((, +# .(((((((((((((((((((((((/ ,((((((((((((((((((((((( +# ((((((((((((((((((((((/ ,(((((((((((((((((((((/ +# *((((((((((((((((((((( (((((((((((((((((((((, +# ,(((((((((((((((((((((, ((((((((((((((((((((/ +# ,(((((((((((((((((((((* /(((((((((((((((((((( +# ((((((((((((((((((((((, ,/((((((((((((((((((((, +# ,((((((((((((((((((((((((((((((((((((((((((((((((((( +# .((((((((((((((((((((((((((((((((((((((((((((( +# .((((((((((((((((((((((((((((((((((((,. +# .,(((((((((((((((((((((((((. +# +################################################################################### diff --git a/web/modules/views_simple_math_field/LICENSE.txt b/web/modules/views_simple_math_field/LICENSE.txt new file mode 100644 index 00000000..d159169d --- /dev/null +++ b/web/modules/views_simple_math_field/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/web/modules/views_simple_math_field/README.md b/web/modules/views_simple_math_field/README.md new file mode 100644 index 00000000..422b8352 --- /dev/null +++ b/web/modules/views_simple_math_field/README.md @@ -0,0 +1,66 @@ +# Views Simple Math Field + +The Views Simple Math Field module creates a Views field handler that enables +the user to perform simple math expressions based on two of the user's +view's fields. + +For a full description of the module, visit the +[project page](https://www.drupal.org/project/views_simple_math_field). + +Submit bug reports and feature suggestions, or track changes in the +[issue queue](https://www.drupal.org/project/issues/views_simple_math_field). + + +## Table of contents + +- Requirements +- Recommended Modules +- Installation +- Configuration +- Maintainers + + +## Requirements + +This module requires andileco/eval-math +(`https://packagist.org/packages/andileco/eval-math`). + + +## Recommended Modules + +Note: Twig can do math, but it's better to perform math with PHP. This field is +especially useful for site-builders who want to avoid Twig or who have a +module like Charts that responds better to this module than a field rewrite. + +- [Charts](https://www.drupal.org/project/charts) + + +## Installation + +Install as you would normally install a contributed Drupal module. For further +information, see +[Installing Drupal Modules](https://www.drupal.org/docs/extending-drupal/installing-drupal-modules). + + +## Configuration + +1. Navigate to Administration > Extend and enable the module. +2. Navigate to Administration > Structure > Views and either create or edit + a view. +3. Add two or more fields that output numbers. +4. Add the `"Global: Simple Math Field"` field (created by this module). + Check as many fields as you need, and then add them into the text area + where you can write formulas with the fields, such as + '(@field_one + @field_two) / @field_three'. Apply and save. + +Please note: +After you enable this module you may need to run cron/clear caches. + + +## Maintainers + +- Daniel Cothran - [andileco](https://www.drupal.org/u/andileco) +- Beakerboy - [Beakerboy](https://www.drupal.org/u/beakerboy) + +**Supporting organization:** +- [John Snow, Inc. (JSI)](https://www.drupal.org/john-snow-inc-jsi) diff --git a/web/modules/views_simple_math_field/composer.json b/web/modules/views_simple_math_field/composer.json new file mode 100644 index 00000000..926ce20c --- /dev/null +++ b/web/modules/views_simple_math_field/composer.json @@ -0,0 +1,16 @@ +{ + "name": "drupal/views_simple_math_field", + "type": "drupal-module", + "description": "Math operations over selected fields", + "homepage": "https://www.drupal.org/project/views_simple_math_field", + "license": "GPL-2.0+", + "minimum-stability": "dev", + "require": { + "andileco/eval-math": "^3.0.1", + "drupal/core": "^8.7|^9.0 || ^10 || ^11" + }, + "support": { + "issues": "https://www.drupal.org/project/issues/views_simple_math_field", + "source": "http://cgit.drupalcode.org/views_simple_math_field" + } +} diff --git a/web/modules/views_simple_math_field/config/schema/views_simple_math_field.views.schema.yml b/web/modules/views_simple_math_field/config/schema/views_simple_math_field.views.schema.yml new file mode 100644 index 00000000..55ecc398 --- /dev/null +++ b/web/modules/views_simple_math_field/config/schema/views_simple_math_field.views.schema.yml @@ -0,0 +1,27 @@ +views.field.field_views_simple_math_field: + type: views_field + label: 'Simple Math Field' + mapping: + fieldset_one: + label: 'Fieldset One' + type: mapping + data_fields: + type: sequence + label: 'Data Fields' + sequence: + type: string + label: 'Data Field' + formula: + label: 'Formula' + type: string + mute_logs: + type: boolean + label: 'Mute database logs for this field' + +views.sort.views_simple_math_field_sort: + type: views_sort + label: 'Simple Math Fields' + mapping: + simple: + label: 'Simple math fields' + type: string diff --git a/web/modules/views_simple_math_field/ludwig.json b/web/modules/views_simple_math_field/ludwig.json new file mode 100644 index 00000000..d91b1eff --- /dev/null +++ b/web/modules/views_simple_math_field/ludwig.json @@ -0,0 +1,8 @@ +{ + "require": { + "andileco/eval-math": { + "version": "3.0.1", + "url": "https://github.com/andileco/eval-math/archive/3.0.1.zip" + } + } +} diff --git a/web/modules/views_simple_math_field/src/Plugin/views/field/SimpleMathField.php b/web/modules/views_simple_math_field/src/Plugin/views/field/SimpleMathField.php new file mode 100644 index 00000000..c9b9f819 --- /dev/null +++ b/web/modules/views_simple_math_field/src/Plugin/views/field/SimpleMathField.php @@ -0,0 +1,514 @@ +entityTypeManager = $entityTypeManager; + } + + /** + * {@inheritdoc} + */ + public function usesGroupBy() { + + return FALSE; + } + + /** + * Sets the initial field data at zero. + */ + public function query() { + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + + // Give this field and alias. + $this->field_alias = 'field_views_simple_math_field'; + + $options['fieldset_one']['default'] = NULL; + $options['fieldset_one']['data_fields'] = ['default' => NULL]; + $options['fieldset_one']['formula'] = ['default' => NULL]; + $options['mute_logs'] = ['default' => 0]; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + $fieldDelta = preg_replace('[\D]', '', $this->options['id']); + $fieldList = $this->displayHandler->getFieldLabels(); + foreach ($fieldList as $key => $value) { + if ($this->field_alias === $key && $fieldDelta < preg_replace('[\D]', '', $key)) { + unset($fieldList[$key]); + } + else { + $fieldList[$key] .= new FormattableMarkup(". Formula token: @%field", ["%field" => $key]); + } + } + unset($fieldList[$this->options['id']]); + $form['fieldset_one'] = [ + '#type' => 'fieldset', + '#title' => t('Select the fields to use in the formula.'), + '#collapsible' => FALSE, + '#collapsed' => FALSE, + '#weight' => -10, + '#required' => TRUE, + ]; + $form['fieldset_one']['data_field'] = [ + '#type' => 'checkboxes', + '#title' => t('Data Fields'), + '#options' => $fieldList, + '#default_value' => $this->options['fieldset_one']['data_field'], + '#weight' => -10, + ]; + $form['fieldset_one']['formula'] = [ + '#type' => 'textarea', + '#title' => t('Formula'), + '#default_value' => $this->options['fieldset_one']['formula'], + '#weight' => -8, + '#description' => t('Enter the formula to give this field its value. You can use any fields specified in the checkboxes above, using the formula token listed beside the field name. It uses the EvalMath library, refer to this web to see math expressions: project in github.', [ + ':url' => Url::fromUri('https://github.com/andileco/eval-math') + ->toString(), + ]), + ]; + $form['mute_logs'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Mute database logs for this field'), + '#default_value' => $this->options['mute_logs'], + '#weight' => -7, + '#description' => t('Normally, a DivisionByZeroException will occur if you attempt to divide a number by zero within your Views Simple Math Field. If you are plotting data containing zeros, for example, and know that this exception is likely to occur, you can check this box to prevent logging of this exception in your Drupal database log.'), + ]; + + return $form; + } + + /** + * Determine the field type we are dealing with. + * + * @param string $field + * The view field name string. + * + * @return string + * The type of field as understood by Views. + */ + protected function getFieldType($field) { + $field_handler = $this->displayHandler->getHandler('field', $field)->options; + if (!empty($field_handler['type'])) { + $field_type = $field_handler['type']; + } + else { + $field_type = 'undefined'; + } + + return $field_type; + } + + /** + * Determine the field plugin we are dealing with. + * + * @param string $field + * A view field name string. + * + * @return string + * The field plugin ID. + */ + protected function getFieldPlugin($field) { + $field_handler = $this->displayHandler->getHandler('field', $field)->options; + if (!empty($field_handler['plugin_id'])) { + $field_plugin = $field_handler['plugin_id']; + } + else { + $field_plugin = 'undefined'; + } + + return $field_plugin; + } + + /** + * Determine if the field comes from a relationship. + * + * @param string $field + * The view field name string. + * + * @return mixed + * The relationship as defined by Views. + */ + protected function getFieldRelationship($field) { + $field_handler = $this->displayHandler->getHandler('field', $field)->options; + if (!empty($field_handler['relationship']) && $field_handler['relationship'] !== 'none') { + $relationship = $field_handler['relationship']; + } + else { + $relationship = NULL; + } + + return $relationship; + } + + /** + * Determine if the field is rewritten/altered. + * + * @param string $field + * The view field name string. + * + * @return mixed + * The text of the rewritten field. + */ + protected function getRewriteStatus($field) { + $field_handler = $this->displayHandler->getHandler('field', $field)->options; + if (isset($field_handler['alter']['alter_text']) && !empty($field_handler['alter']['text'])) { + $alter = $field_handler['alter']['text']; + } + else { + $alter = NULL; + } + + return $alter; + } + + /** + * Get the entity referenced in the view relationship. + * + * @param \Drupal\views\ResultRow $values + * The ResultRow object. + * @param string $field + * The view field name string. + * @param mixed $relationship + * The View relationship. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * The relationship entity. + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected function getRelationshipEntity(ResultRow $values, $field, $relationship) { + $relationship_entity = NULL; + // Get the entity type of the relationship. + $relationship_entity_type = $this->displayHandler + ->getHandler('field', $field)->getEntityType(); + $relationship_entities = $values->_relationship_entities; + // First check the referenced entity. + if (isset($relationship_entities[$relationship])) { + // Get the id of the relationship entity. + $entity_id = $relationship_entities[$relationship]->id(); + // Get the data of the relationship entity. + $relationship_entity = $this->entityTypeManager + ->getStorage($relationship_entity_type) + ->load($entity_id); + } + + return $relationship_entity; + } + + /** + * Remove thousands marker. + * + * @param string $field + * The view field name string. + * @param mixed $data + * The number that may need a thousands separator removed. + * + * @return mixed + * The resulting number with thousands separator removed. + */ + protected function removeSeparator($field, $data) { + if (!empty($this->view->field[$field]->options['separator'])) { + $separator = $this->view->field[$field]->options['separator']; + } + if (!empty($separator)) { + if (strpos($data, $separator)) { + $data = str_replace($separator, '', $data); + } + } + /* + * Even if a separator has not been selected, your data may have a + * common separator already applied. This is a very U.S.-centric + * approach, so open to other ideas here! + */ + else { + if (strpos($data, ',')) { + $data = str_replace(',', '', $data); + } + if (strpos($data, ' ')) { + $data = str_replace(' ', '', $data); + } + } + + return $data; + } + + /** + * Get the value of a simple math field. + * + * @param \Drupal\views\ResultRow $values + * Row results. + * @param \Drupal\Core\Entity\EntityInterface|null $entity + * The current row entity. + * @param bool $field + * The field we are fetching. + * + * @return mixed + * The field value. + * + * @throws \Exception + */ + protected function getFieldValue(ResultRow $values, $entity, $field) { + // Determine what type of field is being used. + $field_type = $this->getFieldType($field); + // Determine what type of field plugin is being used. + $field_plugin = $this->getFieldPlugin($field); + // If the field is rewritten, get the rewritten text. Else, returns null. + $rewritten = $this->getRewriteStatus($field); + // Check if the display is aggregated. Not needed right now. + // $isAggregated = $this->view->getDisplay()->getOption('group_by'); + $data = NULL; + // Compatibility with the views_entity_form_field module. + if ($field_plugin === 'entity_form_field') { + $field_handler = $this->displayHandler->getHandler('field', $field)->options; + if (!empty($field_handler['plugin']['type'])) { + $field_type = $field_handler['plugin']['type']; + } + if (!empty($field_handler['field'])) { + $form_field = $field_handler['field']; + $prefix = 'form_field_'; + if (0 === strpos($form_field, $prefix)) { + $field = substr($form_field, strlen($prefix)); + } + } + $relationship = $this->getFieldRelationship($field); + if ($relationship) { + // Use the relationship's entity to fetch the field value. + $entity = $this->getRelationshipEntity($values, $field, $relationship); + } + // For Commerce fields. + if (in_array($field_type, $this->commercePriceFields)) { + $commerce_field_id = $this->displayHandler->getHandler('field', 'form_field_' . $field)->options['id']; + if ($entity->hasField($commerce_field_id) && !empty($entity->get($commerce_field_id)->getValue())) { + $data = $entity->get($commerce_field_id)->first()->toPrice(); + } + } + else { + $data = $entity->get($field)->getValue()[0]['value']; + } + + return $data; + } + + // Process if not undefined. + if ($field_type !== 'undefined') { + // Get the value of a field that comes from a relationship. + $relationship = $this->getFieldRelationship($field); + if ($relationship) { + // Use the relationship's entity to fetch the field value. + $entity = $this->getRelationshipEntity($values, $field, $relationship); + } + // The next two statements handle fields with or without a relationship. + if (isset($entity) && $rewritten) { + // If already numeric, there is no need for advancedRender(). + if (is_numeric($rewritten) == TRUE) { + $data = $rewritten; + } + else { + // @todo Rewrite using dependency injection. + if (\Drupal::routeMatch()->getRouteName() == 'entity.view.preview_form') { + \Drupal::service('messenger') + ->addMessage(t('It appears that @field is rewritten and requires advanced rendering. Do not use the Views Simple Math Field sort handler for this View.', [ + '@field' => $field, + ]), 'warning'); + } + $data = $this->view->field[$field]->advancedRender($values); + } + } + if (isset($entity) && !$rewritten) { + // Gets the value from the row, which works when aggregated (or not). + $data = $this->view->field[$field]->getValue($values); + + /* + * Keeping these here, but I've commented out in favor of the above + * line, as this was not working with aggregation. + * $field_base = $this->displayHandler + * ->getHandler('field', $field)->field; + * if ($entity->hasField($field_base)) { + * $data = $entity->get($field_base)->getValue()[0]['value']; + * } + */ + + // For Commerce fields. + if (in_array($field_type, $this->commercePriceFields)) { + $commerce_field_id = $this->displayHandler->getHandler('field', $field)->options['id']; + if ($entity->hasField($commerce_field_id) && !empty($entity->get($commerce_field_id)->getValue())) { + $data = $entity->get($commerce_field_id)->first()->toPrice(); + } + } + } + } + /* + * Some fields, such as fields from Views Database Connector, pull + * directly from the database or an index. This attempts to handle those + * types of fields. + */ + else { + if (isset($this->view->field[$field]->original_value)) { + $data = $this->view->field[$field]->original_value; + } + // Decided to leave this as a fallback. + else { + $data = $this->view->field[$field]->getValue($values); + } + + if ($rewritten) { + // @todo Rewrite using dependency injection. + if (\Drupal::routeMatch()->getRouteName() == 'entity.view.preview_form') { + \Drupal::service('messenger') + ->addMessage(t('Views Simple Math Field sometimes has difficulty rendering the correct value for rewritten fields. You may want to double check that field ID @field is properly outputting a value.', [ + '@field' => $field, + ]), 'warning'); + } + $data = $this->displayHandler->getHandler('field', $field) + ->advancedRender($values); + } + } + + // There's no value. Default to 0. + if (!isset($data)) { + $data = 0; + } + + // Remove the thousands marker. + $data = $this->removeSeparator($field, $data); + + return $data; + } + + /** + * {@inheritdoc} + * + * @throws \Exception + */ + public function getValue(ResultRow $values, $field = NULL) { + parent::getValue($values, $field); + + // If no fields are selected as data fields, return NULL. + if (!isset($this->options['fieldset_one']['data_field'])) { + return NULL; + } + + // Get the entity from the row. + $entity = $this->getEntityOrNull($values); + + // Collect all the fields checked. + $fields_in_formula = []; + foreach ($this->options['fieldset_one']['data_field'] as $key => $value) { + if ($value) { + $raw_field_in_formula = $this->getFieldValue($values, $entity, $key); + // Filter and sanitize out the float values. + if (preg_match('/^.*?([\d]+(?:\.[\d]+)?).*?$/', $raw_field_in_formula, $match)) { + $raw_field_in_formula_sane = floatval($match[0]); + $fields_in_formula['@' . $key] = filter_var($raw_field_in_formula_sane, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION); + } + else { + $fields_in_formula['@' . $key] = 0; + } + } + } + // Format the numbers to a string that EvalMath can evaluate. + $formula = new FormattableMarkup($this->options['fieldset_one']['formula'], $fields_in_formula); + $m = new EvalMath(); + try { + $result = $m->evaluate($formula); + } + catch (DivisionByZeroException $e) { + if ($this->options['mute_logs'] != 1) { + \Drupal::logger('views_simple_math_field') + ->error('DivisionByZeroException'); + } + } + + // Return the value if evaluate is successful. + return $result ?? NULL; + } + + /** + * Get the entity from the row. + * + * @param \Drupal\views\ResultRow $values + * The ResultRow object. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * The entity. + */ + public function getEntityOrNull(ResultRow $values): ?object { + // Get the entity from the row. + if (!empty($values->_entity)) { + return $this->getEntity($values); + } + else { + return NULL; + } + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static($configuration, $plugin_id, $plugin_definition, + $container->get('entity_type.manager') + ); + } + +} diff --git a/web/modules/views_simple_math_field/src/Plugin/views/sort/ViewsSimpleMathFieldSort.php b/web/modules/views_simple_math_field/src/Plugin/views/sort/ViewsSimpleMathFieldSort.php new file mode 100644 index 00000000..81d45ddc --- /dev/null +++ b/web/modules/views_simple_math_field/src/Plugin/views/sort/ViewsSimpleMathFieldSort.php @@ -0,0 +1,88 @@ + '']; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function postExecute(&$values) { + $view = $this->view; + $order = $this->options['order']; + foreach ($this->view->result as $result) { + $sm_field = $view->field[$this->options['simple']]->getValue($result); + $this->view->result[$result->index]->{$this->options['simple']} = $sm_field; + } + if ($order === 'ASC') { + usort($this->view->result, function ($item1, $item2) { + return $item1->{$this->options['simple']} <=> $item2->{$this->options['simple']}; + }); + } + else { + usort($this->view->result, function ($item1, $item2) { + return $item2->{$this->options['simple']} <=> $item1->{$this->options['simple']}; + }); + } + + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + protected function showSortForm(&$form, FormStateInterface $form_state) { + parent::showSortForm($form, $form_state); + $simpleOptions = []; + $fieldsOptions = $this->displayHandler->getFieldLabels(); + foreach ($fieldsOptions as $key => $value) { + if (strpos($key, 'field_views_simple_math_field') !== FALSE) { + $simpleOptions[$key] = $value; + } + } + if (!empty($simpleOptions)) { + $form['simple'] = [ + '#title' => $this->t('Simple math fields'), + '#type' => 'radios', + '#options' => $simpleOptions, + '#default_value' => $this->options['simple'], + '#required' => TRUE + ]; + } + } + + /** + * {@inheritdoc} + */ + public function query() { + } + +} diff --git a/web/modules/views_simple_math_field/views_simple_math_field.info.yml b/web/modules/views_simple_math_field/views_simple_math_field.info.yml new file mode 100644 index 00000000..e2e6c21d --- /dev/null +++ b/web/modules/views_simple_math_field/views_simple_math_field.info.yml @@ -0,0 +1,12 @@ +name: Views Simple Math Field +description: 'Creates a Views field handler that enables you to perform simple math expressions' +type: module +package: Views +core_version_requirement: ^8.8 || ^9 || ^10 || ^11 +dependencies: + - drupal:views + +# Information added by Drupal.org packaging script on 2024-06-26 +version: '3.0.2' +project: 'views_simple_math_field' +datestamp: 1719379403 diff --git a/web/modules/views_simple_math_field/views_simple_math_field.views.inc b/web/modules/views_simple_math_field/views_simple_math_field.views.inc new file mode 100644 index 00000000..3abb7bf0 --- /dev/null +++ b/web/modules/views_simple_math_field/views_simple_math_field.views.inc @@ -0,0 +1,30 @@ + [], + ]; + $data['views_simple_math_field']['field_views_simple_math_field'] = [ + 'title' => t('Simple Math Field'), + 'help' => t('Views field that performs simple math expressions.'), + 'field' => [ + 'id' => 'field_views_simple_math_field', + // Allow users to specify how precise the results should be. Useful for divisions + 'float' => TRUE, + ], + 'sort' => [ + 'id' => 'views_simple_math_field_sort', + ], + ]; + return $data; +} diff --git a/web/sites/default/files/db.sqlite b/web/sites/default/files/db.sqlite index 43670906bcee1baf632cd1803a768a2be0e8670a..4169815b01af42d9b59dd3ad3abea83f88128cfe 100644 GIT binary patch delta 143132 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 delta 124879 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 C?*6a<