charts-8.x-4.x-dev/src/Element/ChartDataCollectorTable.php
src/Element/ChartDataCollectorTable.php
<?php
namespace Drupal\charts\Element;
use Drupal\Component\Utility\Environment;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element\FormElementBase;
use Drupal\charts\ColorHelperTrait;
use Drupal\charts\Plugin\chart\Library\ChartInterface;
/**
* Provides a chart data collector table form element.
*
* @FormElement("chart_data_collector_table")
*/
class ChartDataCollectorTable extends FormElementBase {
use ColorHelperTrait;
use ElementFormStateTrait;
const FIRST_COLUMN = 'first_column';
const FIRST_ROW = 'first_row';
/**
* {@inheritdoc}
*/
public function getInfo() {
$class = get_class($this);
return [
'#input' => 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' => '<div id="' . $wrapper_id . '">',
'#suffix' => '</div>',
// 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' => [
'FileExtension' => ['extensions' => 'csv'],
'FileSizeLimit' => ['fileLimit' => 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.
* @param array $form
* The form array the element was added on.
*/
public static function validateDataCollectorTable(array $element, FormStateInterface $form_state, array $form) {
$parents = $element['#parents'];
$value = $form_state->getValue($parents);
// Remove empty rows and unneeded keys.
$i = 0;
foreach ($value['data_collector_table'] as $row_key => $row) {
if (!is_numeric($row_key)) {
unset($value['data_collector_table'][$row_key]);
$i++;
continue;
}
foreach ($row as $column_key => $column) {
if (!is_numeric($column_key)) {
unset($value['data_collector_table'][$row_key][$column_key]);
}
if ($i > 0 && is_numeric($column_key) && $column_key >= 1) {
$cell_value = is_array($column) && isset($column['data']) ? $column['data'] : $column;
if (!static::isValidCellValue($cell_value)) {
$form_state->setError($element['data_collector_table'][$row_key][$column_key]['data'], t('Invalid @value cell value. It should be a number or a comma separated list of numbers. E.g. 1,45,3,46,5 or 5', [
'@value' => $cell_value,
]));
}
}
}
$i++;
}
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();
}
/**
* Checks if the provided cell value is valid.
*
* @param int|float|string $cell_value
* The cell value to validate.
*
* @return bool
* True if the cell value is valid. False otherwise.
*/
private static function isValidCellValue(int|float|string $cell_value): bool {
$cell_value = trim($cell_value);
if (is_numeric($cell_value) || $cell_value === '') {
return TRUE;
}
if (!str_contains($cell_value, ',')) {
return FALSE;
}
$values = array_map('trim', explode(',', $cell_value));
foreach ($values as $value) {
if (!is_numeric($value)) {
return FALSE;
}
}
return TRUE;
}
/**
* 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 = [];
$multi_values_types = ['scatter', 'bubble', 'candlestick', 'boxplot'];
$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 a 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, $multi_values_types)) {
// Enclose the data value in an array.
$series[$i]['data'] = [$series[$i]['data']];
}
$i++;
continue;
}
$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;
}
if ($i === 0) {
// This is the first column that holds the data names and colors.
$series[$j]['name'] = $column['data'] ?? $column;
$series[$j]['color'] = $column['color'] ?? self::randomColor();
$j++;
continue;
}
// 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, $multi_values_types) && is_numeric($cell_value)) {
$series[$j]['data'][0][] = $cell_value;
}
else {
$series[$j]['data'][] = $cell_value;
}
$j++;
}
$i++;
}
return $series;
}
/**
* Casts string value to numeric.
*
* @param string|int|float $value
* The value.
*
* @return float|int|null|array
* The numeric value.
*/
private static function castValueToNumeric(string|int|float $value): float|int|null|array {
if (is_numeric($value)) {
return is_int($value) ? (integer) $value : (float) $value;
}
// When a value is empty string, or it's not comma separated string let
// return NULL.
if ($value === '' || !str_contains($value, ',')) {
return NULL;
}
// Explode by comma and trim each element.
// And ensure values in the array are cast to numeric.
return array_map(
'floatval',
array_map('trim', explode(',', $value))
);
}
}
