diff --git a/README.md b/README.md index 122671e..69e0180 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ # Agile Charts - Integration for ChartJS into Agile Toolkit ChartJS is an open-source JavaScript library. This add-on -offers a deep integration betwene Agile UI, Agile Data and -chartJS. +offers a deep integration between Agile UI, Agile Data and +chartJS library. -![demo](demo.png) +![demo](docs/demo1.png) +![demo](docs/demo2.png) +![demo](docs/demo3.png) +![demo](docs/demo4.png) +![demo](docs/demo5.png) diff --git a/composer.json b/composer.json index 01eeaf7..847d9ad 100644 --- a/composer.json +++ b/composer.json @@ -20,11 +20,11 @@ ], "homepage": "https://github.com/atk4/chart", "require": { - "php": ">=7.4 <8.2", + "php": ">=7.4 <8.3", "atk4/ui": "dev-develop" }, "require-release": { - "php": ">=7.4 <8.2", + "php": ">=7.4 <8.3", "atk4/ui": "~4.0.0" }, "require-dev": { diff --git a/demo.png b/demo.png deleted file mode 100644 index aa058cb..0000000 Binary files a/demo.png and /dev/null differ diff --git a/demos/index.php b/demos/index.php index 217d2b6..a2cbcf9 100644 --- a/demos/index.php +++ b/demos/index.php @@ -5,9 +5,16 @@ namespace Atk4\Chart\Demos; use Atk4\Chart\BarChart; +use Atk4\Chart\BubbleChart; +use Atk4\Chart\Chart; use Atk4\Chart\ChartBox; +use Atk4\Chart\ColorGenerator; +use Atk4\Chart\DoughnutChart; use Atk4\Chart\LineChart; use Atk4\Chart\PieChart; +use Atk4\Chart\PolarAreaChart; +use Atk4\Chart\RadarChart; +use Atk4\Chart\ScatterChart; use Atk4\Data\Model; use Atk4\Data\Persistence; use Atk4\Ui\App; @@ -16,58 +23,183 @@ require '../vendor/autoload.php'; +// setup example data model $t = [ - 1 => ['name' => 'January', 'sales' => 20000, 'purchases' => 10000], - 2 => ['name' => 'February', 'sales' => 23000, 'purchases' => 12000], - 3 => ['name' => 'March', 'sales' => 16000, 'purchases' => 25000], - 4 => ['name' => 'April', 'sales' => 14000, 'purchases' => 13000], + 1 => ['name' => 'January', 'sales_cash' => 6_000, 'sales_bank' => 14_000, 'purchases' => 10_000], + 2 => ['name' => 'February', 'sales_cash' => 5_000, 'sales_bank' => 18_000, 'purchases' => 12_000], + 3 => ['name' => 'March', 'sales_cash' => 4_000, 'sales_bank' => 12_000, 'purchases' => 22_000], + 4 => ['name' => 'April', 'sales_cash' => 7_500, 'sales_bank' => 6_500, 'purchases' => 13_000], + 5 => ['name' => 'May', 'sales_cash' => 3_000, 'sales_bank' => 8_500, 'purchases' => 9_000], ]; $m = new Model(new Persistence\Array_($t)); -$m->addFields(['name', 'sales', 'purchases', 'profit']); -$m->onHook($m::HOOK_AFTER_LOAD, function ($m) { +$m->addFields(['name', 'sales_cash', 'sales_bank', 'sales', 'purchases', 'profit']); +$m->onHook($m::HOOK_AFTER_LOAD, function (Model $m) { + $m->set('sales', $m->get('sales_cash') + $m->get('sales_bank')); $m->set('profit', $m->get('sales') - $m->get('purchases')); }); + +// setup app $app = new App(['title' => 'Chart Demo']); $app->initLayout([Layout\Centered::class]); -// split in columns - basic charts +// split in columns - Bar Chart $columns = Columns::addTo($app->layout); // lets put your chart into a box $cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bar Chart', 'icon' => 'book']]); $chart = BarChart::addTo($cb); $chart->setModel($m, ['name', 'sales', 'purchases', 'profit']); -$chart->withCurrency('$'); // tweak our chart to support currencies better +$chart->setCurrencyLabel('$'); // tweak our chart to support currencies better + +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bar Chart Stacked', 'icon' => 'book']]); +$chart = BarChart::addTo($cb); +$chart->setModel($m, ['name', 'sales_cash', 'sales_bank', 'purchases', 'profit']); +$chart->setStacks([ + 'Stack 1' => ['sales_cash', 'sales_bank'], + 'Stack 2' => ['purchases'], +]); +$chart->setCurrencyLabel('$'); + +// split in columns - Bar Chart horizontal +$columns = Columns::addTo($app->layout); $cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bar Chart Horizontal', 'icon' => 'book']]); $chart = BarChart::addTo($cb); $chart->setHorizontal(); $chart->setModel($m, ['name', 'sales', 'purchases', 'profit']); -$chart->withCurrency('$'); +$chart->setCurrencyLabel('$'); -// split in columns - stacked charts +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bar Chart Horizontal', 'icon' => 'book']]); +$chart = BarChart::addTo($cb); +$chart->setHorizontal(); +$chart->setModel($m, ['name', 'sales_cash', 'sales_bank', 'purchases', 'profit']); +$chart->setStacks([ + 'Stack 1' => ['sales_cash', 'sales_bank'], + 'Stack 2' => ['purchases'], +]); +$chart->setCurrencyLabel('$'); + +// split in columns - Line Chart $columns = Columns::addTo($app->layout); -$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bar Chart Stacked', 'icon' => 'book']]); -$chart = BarChart::addTo($cb); -$chart->setModel($m, ['name', 'sales', 'purchases', 'profit'], ['Stack 1', 'Stack 2', 'Stack 1']); // Stack 1 => sales + profit, Stack 2 => purchases -$chart->withCurrency('$'); +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Line Chart', 'icon' => 'book']]); +$chart = LineChart::addTo($cb); +$chart->setModel($m, ['name', 'sales', 'purchases', 'profit']); +$chart->setCurrencyLabel('$'); -$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bar Chart Stacked', 'icon' => 'book']]); -$chart = BarChart::addTo($cb); -$chart->setModel($m, ['name', 'sales', 'purchases', 'profit'], [1, 1, 1]); // 1 => sales + purchases + profit -$chart->withCurrency('$'); +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Line Chart Stacked', 'icon' => 'book']]); +$chart = LineChart::addTo($cb); +$chart->setModel($m, ['name', 'sales_cash', 'sales_bank', 'purchases', 'profit']); +$chart->setStacks([ + 'Stack 1' => ['sales_cash', 'sales_bank'], + 'Stack 2' => ['purchases'], +]); +$chart->setCurrencyLabel('$'); + +// split in columns - Line Chart Vertical and filled +$columns = Columns::addTo($app->layout); + +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Line Chart Filled', 'icon' => 'book']]); +$chart = LineChart::addTo($cb); +$chart->setModel($m, ['name', 'sales', 'purchases', 'profit']); +$chart->setColumnOptions([ + 'profit' => ['fill' => true], +]); +$chart->setCurrencyLabel('$'); + +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Line Chart Vertical', 'icon' => 'book']]); +$chart = LineChart::addTo($cb); +$chart->setModel($m, ['name', 'sales', 'purchases', 'profit']); +$chart->setVertical(); +$chart->setCurrencyLabel('$'); -// split in columns - more charts +// split in columns - Line + Bar Chart +$columns = Columns::addTo($app->layout); + +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bar + Line Chart', 'icon' => 'book']]); +$chart = LineChart::addTo($cb); +$chart->setModel($m, ['name', 'profit', 'sales', 'purchases']); +$chart->setColumnOptions([ + 'profit' => ['type' => Chart::TYPE_LINE], + 'sales' => ['type' => Chart::TYPE_BAR], + 'purchases' => ['type' => Chart::TYPE_BAR], +]); +$chart->setCurrencyLabel('$'); + +// split in columns - Pie Chart $columns = Columns::addTo($app->layout); $cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Pie Chart', 'icon' => 'book']]); $chart = PieChart::addTo($cb); -$chart->setModel($m, ['name', 'purchases']); -$chart->withCurrency('$'); +$chart->setModel($m, ['name', 'sales', 'purchases']); +$chart->setCurrencyLabel('$'); -$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Line Chart', 'icon' => 'book']]); -$chart = LineChart::addTo($cb); -$chart->setModel($m, ['name', 'profit']); -$chart->withCurrency('$'); +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Doughnut Chart', 'icon' => 'book']]); +$chart = DoughnutChart::addTo($cb); +$chart->setModel($m, ['name', 'sales', 'purchases']); +$chart->setCurrencyLabel('$'); + +// split in columns - Radar and Polar Area charts +$columns = Columns::addTo($app->layout); + +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Radar Chart', 'icon' => 'book']]); +$chart = RadarChart::addTo($cb); +$chart->setModel($m, ['name', 'sales', 'purchases', 'profit']); +$chart->setCurrencyLabel('$'); + +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Polar Area Chart', 'icon' => 'book']]); +$chart = PolarAreaChart::addTo($cb); +$chart->setModel($m, ['name', 'sales_cash', 'sales']); +$chart->setCurrencyLabel('$'); + +// setup example data model +$t = [ + 1 => ['name' => 'Sahara', 'trees' => 100, 'cars' => 200, 'pollution' => 4], + 2 => ['name' => 'London', 'trees' => 500, 'cars' => 3_100, 'pollution' => 50], + 3 => ['name' => 'Riga', 'trees' => 300, 'cars' => 700, 'pollution' => 13], + 4 => ['name' => 'Paris', 'trees' => 450, 'cars' => 2_800, 'pollution' => 35], + 5 => ['name' => 'Mars', 'trees' => 350, 'cars' => 2_500, 'pollution' => 20], +]; + +$m = new Model(new Persistence\Array_($t), ['caption' => 'Pollution']); +$m->addFields(['name', 'trees', 'cars', 'pollution']); + +// Scatter and Bubble charts +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Scatter Chart', 'icon' => 'book']]); +$chart = ScatterChart::addTo($cb); +$chart->setModel($m, ['name', 'trees', 'cars', 'pollution']); +$chart->setAxisTitles(); + +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bubble Chart - dataset from model', 'icon' => 'book']]); +$chart = BubbleChart::addTo($cb); +$chart->setModel($m, ['name', 'trees', 'cars', 'pollution']); +$chart->setAxisTitles(); + +// custom bubble chart without model but with multiple manually set datasets +$cb = ChartBox::addTo($columns->addColumn(8), ['label' => ['Bubble Chart - multiple datasets', 'icon' => 'book']]); +$chart = BubbleChart::addTo($cb); +$colorGenerator = new ColorGenerator(); +$chart->setDatasets([ + [ + 'label' => 'Population', + 'backgroundColor' => $colorGenerator->getColorPairByIndex(0)[0], + 'borderColor' => $colorGenerator->getColorPairByIndex(0)[1], + 'data' => [ + ['x' => 30, 'y' => 50, 'r' => 10], + ['x' => 10, 'y' => 20, 'r' => 50], + ['x' => 20, 'y' => 30, 'r' => 30], + ], + ], + [ + 'label' => 'Pollution', + 'backgroundColor' => $colorGenerator->getColorPairByIndex(1)[0], + 'borderColor' => $colorGenerator->getColorPairByIndex(1)[1], + 'data' => [ + ['x' => 15, 'y' => 30, 'r' => 5], + ['x' => 10, 'y' => 10, 'r' => 20], + ['x' => 25, 'y' => 40, 'r' => 10], + ], + ], +]); +$chart->setAxisTitles(); diff --git a/docs/demo1.png b/docs/demo1.png new file mode 100644 index 0000000..3db401e Binary files /dev/null and b/docs/demo1.png differ diff --git a/docs/demo2.png b/docs/demo2.png new file mode 100644 index 0000000..482a4d7 Binary files /dev/null and b/docs/demo2.png differ diff --git a/docs/demo3.png b/docs/demo3.png new file mode 100644 index 0000000..64908ba Binary files /dev/null and b/docs/demo3.png differ diff --git a/docs/demo4.png b/docs/demo4.png new file mode 100644 index 0000000..403da55 Binary files /dev/null and b/docs/demo4.png differ diff --git a/docs/demo5.png b/docs/demo5.png new file mode 100644 index 0000000..7950987 Binary files /dev/null and b/docs/demo5.png differ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0b95129..e9a9172 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,3 @@ parameters: ignoreErrors: # relax strict rules - '~^Only booleans are allowed in .+, .+ given( on the (left|right) side)?\.~' - - # for src/Chart.php - - '~^Call to an undefined method Atk4\\Data\\Model::expr\(\)\.$~' diff --git a/src/BarChart.php b/src/BarChart.php index dfc8063..a6f6093 100644 --- a/src/BarChart.php +++ b/src/BarChart.php @@ -6,16 +6,20 @@ class BarChart extends Chart { - public $type = 'bar'; + use DirectionTrait; + use StackedTrait; + + public string $type = self::TYPE_BAR; /** - * Set this chart to be horizontal. + * @param array|string $label */ - public function setHorizontal(): void + public function __construct($label = []) { - $this->type = 'horizontalBar'; + // Bar chart understand axis opposite as Line chart + $this->horizontalAxis = 'y'; + $this->verticalAxis = 'x'; - // in chartjs 3.9.1 replace with - // $this->setOptions(['indexAxis' => 'y']); + parent::__construct($label); } } diff --git a/src/BubbleChart.php b/src/BubbleChart.php new file mode 100644 index 0000000..1114815 --- /dev/null +++ b/src/BubbleChart.php @@ -0,0 +1,10 @@ + We will use these colors in charts */ - public $niceColors = [ - ['rgba(255, 99, 132, 0.2)', 'rgba(255,99,132,1)'], - ['rgba(54, 162, 235, 0.2)', 'rgba(54, 162, 235, 1)'], - ['rgba(255, 206, 86, 0.2)', 'rgba(255, 206, 86, 1)'], - ['rgba(75, 192, 192, 0.2)', 'rgba(75, 192, 192, 1)'], - ['rgba(153, 102, 255, 0.2)', 'rgba(153, 102, 255, 1)'], - ['rgba(255, 159, 64, 0.2)', 'rgba(255, 159, 64, 1)'], - ['rgba(20, 20, 20, 0.2)', 'rgba(20, 20, 20, 1)'], - ]; - /** @var array Options for chart.js widget */ public $options = []; + /** @var array> Options for each data column for chart.js widget */ + public $columnOptions = []; + + /** @var string */ + protected $cdnUrl = 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js'; + + /** @var array Columns (data model fields) used in chart */ + protected $columns; + /** @var array Labels for axis. Fills with setModel(). */ protected $labels; - /** @var array> Datasets. Fills with setModel(). */ + /** @var array> Datasets. Fills with setModel(). */ protected $datasets; + /** @var ColorGenerator */ + protected $colorGenerator; + protected function init(): void { parent::init(); + $this->colorGenerator = new ColorGenerator(); + if ($this->jsInclude) { - $this->getApp()->requireJs('https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.bundle.js'); + $this->getApp()->requireJs($this->cdnUrl); } } @@ -63,7 +91,7 @@ public function renderView(): void /** * @return array */ - public function getConfig(): array + protected function getConfig(): array { return [ 'type' => $this->type, @@ -76,9 +104,9 @@ public function getConfig(): array } /** - * @return array + * @return array|null */ - public function getLabels(): array + protected function getLabels(): ?array { return $this->labels; } @@ -86,15 +114,31 @@ public function getLabels(): array /** * @return array> */ - public function getDatasets(): array + protected function getDatasets(): array { + foreach ($this->columnOptions as $column => $options) { + $this->datasets[$column] = array_merge_recursive($this->datasets[$column], $options); + } + return array_values($this->datasets); } + /** + * @param array> $datasets + * + * @return $this + */ + public function setDatasets(array $datasets) + { + $this->datasets = $datasets; + + return $this; + } + /** * @return array */ - public function getOptions(): array + protected function getOptions(): array { return $this->options; } @@ -112,68 +156,80 @@ public function setOptions(array $options) return $this; } + /** + * @param array> $options column_name => array of options + * + * @return $this + */ + public function setColumnOptions(array $options) + { + // IMPORTANT: use replace not merge here to preserve numeric keys !!! + $this->columnOptions = array_replace_recursive($this->columnOptions, $options); + + return $this; + } + /** * Specify data source for this chart. The column must contain - * the textual column first followed by sumber of data columns: + * the textual column first followed by number of data columns: * setModel($month_report, ['month', 'total_sales', 'total_purchases']);. * * This component will automatically figure out name of the chart, * series titles based on column captions etc. * - * Example for bar chart with two side-by side bars per category, and one of them stacked: - * - * $chart->setModel( - * $model, - * ['month', 'turnover_month_shoes', 'turnover_month_shirts', 'turnover_month_trousers', 'turnover_month_total_last_year'], - * [1, 1, 1, 2] // 1 => shoes+shirts+trousers, 2 => total last year - * ); - * * @param array $columns - * @param array $stacks */ - public function setModel(Model $model, array $columns = [], array $stacks = []): void + public function setModel(Model $model, array $columns = []): void { if ($columns === []) { throw new Exception('Second argument must be specified to Chart::setModel()'); } + $this->columns = $columns; + + parent::setModel($model); + + $this->prepareDatasets(); + } + + /** + * Fills dataset with data from data model. + */ + protected function prepareDatasets(): void + { + if ($this->model === null || $this->columns === null) { + return; + } - $this->datasets = []; + $datasets = []; // initialize data-sets - foreach ($columns as $key => $column) { + foreach ($this->columns as $key => $column) { if ($key === 0) { $titleColumn = $column; continue; // skipping label column } - $colors = array_shift($this->niceColors); - $stack = array_shift($stacks); + $colors = $this->colorGenerator->getNextColorPair(); - $this->datasets[$column] = [ - 'label' => $model->getField($column)->getCaption(), + $datasets[$column] = [ + 'label' => $this->model->getField($column)->getCaption(), 'backgroundColor' => $colors[0], 'borderColor' => $colors[1], 'borderWidth' => 1, 'data' => [], ]; - - if ($stack !== null) { - $this->datasets[$column]['stack'] = $stack; - } - } - - if ($stacks !== []) { - $this->setOptions(['scales' => ['yAxes' => [0 => ['stacked' => true]], 'xAxes' => [0 => ['stacked' => true]]]]); } // prepopulate data-sets - foreach ($model as $entity) { + foreach ($this->model as $entity) { $this->labels[] = $entity->get($titleColumn); // @phpstan-ignore-line - foreach ($this->datasets as $key => &$dataset) { - $dataset['data'][] = $entity->get($key); + foreach ($datasets as $column => $dataset) { + $datasets[$column]['data'][] = $entity->get($column); } } + + $this->setDatasets($datasets); } /** @@ -184,19 +240,37 @@ public function setModel(Model $model, array $columns = [], array $stacks = []): * * @return $this */ - public function withCurrency(string $char = '€', string $axis = 'y') + public function setCurrencyLabel(string $char = '€', string $axis = 'y', int $digits = 2) { - // magic regex adds commas as thousand separators: http://009co.com/?p=598 - $options = []; - $options['scales'][$axis . 'Axes'] = - [['ticks' => [ - 'userCallback' => new JsExpression('{}', ['function(value) { value=Math.round(value*1000000)/1000000; return "' . $char . ' " + value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); }']), - ]]]; - - $options['tooltips'] = [ - 'enabled' => true, - 'mode' => 'single', - 'callbacks' => ['label' => new JsExpression('{}', ['function(item, data) { return item.' . $axis . 'Label ? "' . $char . ' " + item.' . $axis . 'Label.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "No Data"; }'])], + $options = [ + 'scales' => [ + $axis => [ + 'ticks' => [ + 'callback' => new JsFunction(['value', 'index', 'ticks'], [ + new JsExpression('return "' . $char . ' " + Number(value).toLocaleString(undefined, {minimumFractionDigits: ' . $digits . ', maximumFractionDigits: ' . $digits . '})'), + ]), + ], + ], + ], + 'plugins' => [ + 'tooltip' => [ + 'enabled' => true, + 'mode' => 'point', + 'callbacks' => [ + 'label' => new JsFunction(['context'], [ + new JsExpression(' + let label = context.dataset.label || ""; + // let value = context.parsed.y; // or x (horizontal) or r (radar) etc + let value = context.formattedValue.replace(/,/, ""); + if (label) { + label += ": "; + } + return label + (value ? "' . $char . ' " + Number(value).toLocaleString(undefined, {minimumFractionDigits: ' . $digits . ', maximumFractionDigits: ' . $digits . '}) : "No Data"); + '), + ]), + ], + ], + ], ]; $this->setOptions($options); @@ -211,9 +285,9 @@ public function withCurrency(string $char = '€', string $axis = 'y') * * @return $this */ - public function withCurrencyX(string $char = '€') + public function setCurrencyLabelX(string $char = '€', int $digits = 2) { - return $this->withCurrency($char, 'x'); + return $this->setCurrencyLabel($char, 'x', $digits); } /** @@ -223,87 +297,8 @@ public function withCurrencyX(string $char = '€') * * @return $this */ - public function withCurrencyY(string $char = '€') + public function setCurrencyLabelY(string $char = '€', int $digits = 2) { - return $this->withCurrency($char, 'y'); - } - - /** - * Will produce a graph showing summary of a certain model by grouping and aggregating data. - * - * Example: - * - * // Pie or Bar chart - * $chart->summarize($users, ['by' => 'status', 'fx' => 'count']); - * $chart->summarize($users, ['by' => 'status', 'fx' => 'sum', 'field' => 'total_net']); - * - * or - * - * // Bar chart - * $orders = $clients->ref('Orders'); - * $chart->summarize($orders, [ - * 'by'=>$orders->expr('year([date])'), - * 'fields'=>[ - * 'purchase' => $orders->expr('sum(if([is_purchase], [amount], 0)'), - * 'sale' => $orders->expr('sum(if([is_purchase], 0, [amount])'), - * ], - * ])->withCurrency('$'); - * - * @param array $options - * - * @return $this - */ - public function summarize(Model $model, array $options = []) - { - $fields = ['by']; - - // first lets query data - if (isset($options['fields'])) { - $qq = $model->action('select', [[]]); - - // now add fields - foreach ($options['fields'] as $alias => $field) { - if (is_numeric($alias)) { - $alias = $field; - } - if (is_string($field)) { - // sanitization needed! - $field = $model->expr(($options['fx'] ?? '') . '([' . $field . '])'); - } - - $qq->field($field, $alias); - - $fields[] = $alias; - } - } else { - $fx = $options['fx'] ?? 'count'; - if ($fx === 'count') { - $qq = $model->action('count', ['alias' => $fx]); - $fields[] = $fx; - } elseif (isset($options['fx'])) { - $qq = $model->action('fx', [$fx, $options['field'] ?? $model->expr('*'), 'alias' => $fx]); - $fields[] = $fx; - } else { - $qq = $model->action('select', [[$model->titleField]]); - $fields[] = $model->titleField; - } - } - - // next we need to group - if ($options['by'] ?? null) { - $field = $options['by']; - if (is_string($field)) { - $field = $model->getField($field); - } - $qq->field($field, 'by'); - $qq->group('by'); - } else { - $qq->field($model->getField($model->titleField), 'by'); - } - - // and then set it as chart source - $this->setSource($qq->getRows(), $fields); - - return $this; + return $this->setCurrencyLabel($char, 'y', $digits); } } diff --git a/src/ColorGenerator.php b/src/ColorGenerator.php new file mode 100644 index 0000000..c9e654f --- /dev/null +++ b/src/ColorGenerator.php @@ -0,0 +1,75 @@ +> + */ + protected $colors = [ + ['rgba(255, 99, 132, 0.2)', 'rgba(255, 99, 132, 1)'], + ['rgba(54, 162, 235, 0.2)', 'rgba(54, 162, 235, 1)'], + ['rgba(255, 206, 86, 0.2)', 'rgba(255, 206, 86, 1)'], + ['rgba(75, 192, 192, 0.2)', 'rgba(75, 192, 192, 1)'], + ['rgba(153, 102, 255, 0.2)', 'rgba(153, 102, 255, 1)'], + ['rgba(255, 159, 64, 0.2)', 'rgba(255, 159, 64, 1)'], + ['rgba(20, 20, 20, 0.2)', 'rgba(20, 20, 20, 1)'], + ]; + + /** @var int */ + private $currentColorIndex = -1; + + /** + * Return color by index. + * + * @return array + */ + public function getColorPairByIndex(int $i): array + { + return $this->colors[$i % count($this->colors)]; + } + + /** + * Return next color. + * + * @return array + */ + public function getNextColorPair(): array + { + return $this->getColorPairByIndex(++$this->currentColorIndex); + } + + /** + * Return all possible colors. + * + * @return array> + */ + public function getAllColorPairs(): array + { + return $this->colors; + } + + /** + * Set all possible colors. + * + * @param array> $colors + * + * @return $this + */ + public function setAllColorPairs(array $colors) + { + $this->colors = $colors; + + return $this; + } +} diff --git a/src/DirectionTrait.php b/src/DirectionTrait.php new file mode 100644 index 0000000..04ea8d2 --- /dev/null +++ b/src/DirectionTrait.php @@ -0,0 +1,33 @@ +setOptions(['indexAxis' => $this->horizontalAxis]); + } + + /** + * Set this chart to be vertical. + */ + public function setVertical(): void + { + $this->setOptions(['indexAxis' => $this->verticalAxis]); + } +} diff --git a/src/DoughnutChart.php b/src/DoughnutChart.php new file mode 100644 index 0000000..13ca2e5 --- /dev/null +++ b/src/DoughnutChart.php @@ -0,0 +1,10 @@ +datasets = []; + $datasets = []; $colors = []; // initialize data-sets - foreach ($columns as $key => $column) { - $colors[$column] = $this->niceColors; - + foreach ($this->columns as $key => $column) { if ($key === 0) { $titleColumn = $column; - continue; // skipping labels + continue; // skipping label column } - $this->datasets[$column] = [ + $colors[$column] = new ColorGenerator(); + + $datasets[$column] = [ 'data' => [], + 'label' => $this->model->getField($column)->getCaption(), 'backgroundColor' => [], + 'borderColor' => [], + 'borderWidth' => 1, + 'hoverOffset' => 8, + 'borderAlign' => 'inner', ]; } // prepopulate data-sets - foreach ($model as $entity) { + foreach ($this->model as $entity) { $this->labels[] = $entity->get($titleColumn); // @phpstan-ignore-line - foreach ($this->datasets as $key => &$dataset) { - $dataset['data'][] = $entity->get($key); - $color = array_shift($colors[$key]); - $dataset['backgroundColor'][] = $color[0]; - $dataset['borderColor'][] = $color[1]; + foreach ($datasets as $column => $dataset) { + $datasets[$column]['data'][] = $entity->get($column); + $color = $colors[$column]->getNextColorPair(); + $datasets[$column]['backgroundColor'][] = $color[0]; + $datasets[$column]['borderColor'][] = $color[1]; } } + + $this->setDatasets($datasets); } - public function withCurrency(string $char = '€', string $axis = 'y') + public function setCurrencyLabel(string $char = '€', string $axis = 'y', int $digits = 2) { - $options = []; - $options['tooltips'] = [ - 'callbacks' => [ - 'label' => new JsExpression('{}', [ - 'function(item, data, bb) { - var val = data.datasets[item.datasetIndex].data[item.index]; - - return "' . $char . '" + val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); - }', - ]), + $options = [ + 'plugins' => [ + 'tooltip' => [ + 'enabled' => true, + 'mode' => 'point', + 'callbacks' => [ + 'label' => new JsFunction(['context'], [ + new JsExpression(' + let label = context.dataset.label || ""; + // let value = context.parsed; // y or x (horizontal) or r (radar) etc + let value = context.formattedValue.replace(/,/, ""); + if (label) { + label += ": "; + } + return label + (value ? "' . $char . ' " + Number(value).toLocaleString(undefined, {minimumFractionDigits: ' . $digits . ', maximumFractionDigits: ' . $digits . '}) : "No Data"); + '), + ]), + ], + ], ], ]; diff --git a/src/PolarAreaChart.php b/src/PolarAreaChart.php new file mode 100644 index 0000000..d32d0ab --- /dev/null +++ b/src/PolarAreaChart.php @@ -0,0 +1,10 @@ +columns; + + $titleColumn = array_shift($columns) ?? null; + $this->xField = array_shift($columns) ?? 'x'; + $this->yField = array_shift($columns) ?? 'y'; + $this->rField = array_shift($columns) ?? 'r'; + + // initialize data-set + $colors = $this->colorGenerator->getNextColorPair(); + $dataset = [ + 'label' => $this->model->getModelCaption(), + 'backgroundColor' => $colors[0], + 'borderColor' => $colors[1], + 'data' => [], + ]; + + // prepopulate data-sets + foreach ($this->model as $entity) { + $dataset['data'][] = [ + // 'label' => $entity->get($titleColumn), // maybe some day this will be implemented in chartjs to add label to bubble + 'x' => $entity->get($this->xField), + 'y' => $entity->get($this->yField), + 'r' => $entity->get($this->rField), + ]; + } + + $this->setDatasets([$dataset]); + } + + /** + * Add titles on axis. + * + * @param string|null $xTitle X axis title + * @param string|null $yTitle Y axis title + */ + public function setAxisTitles(string $xTitle = null, string $yTitle = null): void + { + $options = [ + 'scales' => [ + 'x' => [ + 'title' => [ + 'text' => $xTitle ?? ($this->model ? $this->model->getField($this->xField)->getCaption() : ''), + 'display' => true, + ], + ], + 'y' => [ + 'title' => [ + 'text' => $yTitle ?? ($this->model ? $this->model->getField($this->yField)->getCaption() : ''), + 'display' => true, + ], + ], + ], + /* @todo maybe this can be used to tweak label to include city names? See example chart in demos. + 'plugins' => [ + 'tooltip' => [ + 'enabled' => true, + 'mode' => 'point', + 'callbacks' => [ + 'label' => new JsFunction(['context'], [ + new JsExpression(' + let label = context.dataset.label || ""; + let value = context.parsed.y; + if (label) { + label += ": "; + } + return label + (value ? Number(value).toLocaleString(undefined, {minimumFractionDigits: ' . $digits . ', maximumFractionDigits: ' . $digits . '}) : "No Data"); + '), + ]), + ], + ], + ], + */ + ]; + + $this->setOptions($options); + } +} diff --git a/src/StackedTrait.php b/src/StackedTrait.php new file mode 100644 index 0000000..2739f86 --- /dev/null +++ b/src/StackedTrait.php @@ -0,0 +1,28 @@ +> $stacks Stack name => array of column names in stack + */ + public function setStacks(array $stacks = []): void + { + if ($stacks !== []) { + $this->setOptions(['scales' => ['x' => ['stacked' => true], 'y' => ['stacked' => true]]]); + + $options = []; + foreach ($stacks as $stack => $columns) { + foreach ($columns as $column) { + $options[$column]['stack'] = $stack; + } + } + $this->setColumnOptions($options); + } + } +}