chartboard/app/Libraries/ChartRenderer.php
2026-04-10 12:10:43 +05:30

1109 lines
40 KiB
PHP

<?php
namespace App\Libraries;
/**
* Maps tabular query results to ApexCharts option objects (JSON-safe; client applies label formatters).
*/
class ChartRenderer
{
/** @var array<string, array<int, string>> */
// private const PALETTES = [
// 'mono' => ['#334155', '#475569', '#64748b', '#94a3b8', '#cbd5e1'],
// 'ocean' => ['#36a2eb', '#4bc0c0', '#a8e6ff', '#5b8ff9', '#2f7ed8'],
// 'forest' => ['#22c55e', '#16a34a', '#86efac', '#65a30d', '#14532d'],
// 'sunset' => ['#ff9f40', '#ffd166', '#f97316', '#fb7185', '#ef4444'],
// 'slate' => ['#0f172a', '#1e293b', '#334155', '#475569', '#64748b'],
// 'violet' => ['#8b5cf6', '#a78bfa', '#c4b5fd', '#d946ef', '#7c3aed'],
// ];
// private const PALETTES = [
// 'mono' => ['#020617','#0f172a','#1e293b','#334155','#475569','#64748b','#94a3b8','#cbd5e1','#e2e8f0','#f1f5f9','#f8fafc','#ffffff'],
// 'ocean' => ['#075985','#0369a1','#0284c7','#0ea5e9','#38bdf8','#7dd3fc','#bae6fd','#e0f2fe','#f0f9ff','#e6f6ff','#cceeff','#99ddff'],
// 'forest' => ['#052e16','#14532d','#166534','#15803d','#16a34a','#22c55e','#4ade80','#86efac','#bbf7d0','#dcfce7','#ecfdf5','#f0fdf4'],
// 'sunset' => ['#7c2d12','#9a3412','#c2410c','#ea580c','#f97316','#fb923c','#fdba74','#fed7aa','#ffedd5','#fff7ed','#fff1e6','#ffe4d6'],
// 'slate' => ['#020617','#020617','#0f172a','#1e293b','#334155','#475569','#64748b','#94a3b8','#cbd5e1','#e2e8f0','#f1f5f9','#f8fafc'],
// 'violet' => ['#2e1065','#4c1d95','#5b21b6','#6d28d9','#7c3aed','#8b5cf6','#a78bfa','#c4b5fd','#ddd6fe','#ede9fe','#f5f3ff','#faf5ff'],
// 'rose' => ['#881337','#9f1239','#be123c','#e11d48','#f43f5e','#fb7185','#fda4af','#fecdd3','#ffe4e6','#fff1f2','#fff5f5','#ffeaea'],
// 'teal' => ['#042f2e','#134e4a','#0f766e','#0d9488','#14b8a6','#2dd4bf','#5eead4','#99f6e4','#ccfbf1','#f0fdfa','#e6fffa','#ccfffa'],
// 'amber' => ['#78350f','#92400e','#b45309','#d97706','#f59e0b','#fbbf24','#fcd34d','#fde68a','#fef3c7','#fffbeb','#fff7cc','#fff2b3'],
// 'indigo' => ['#1e1b4b','#312e81','#3730a3','#4338ca','#4f46e5','#6366f1','#818cf8','#a5b4fc','#c7d2fe','#e0e7ff','#eef2ff','#f5f7ff'],
// 'cool_gray' => ['#030712','#111827','#1f2937','#374151','#4b5563','#6b7280','#9ca3af','#d1d5db','#e5e7eb','#f3f4f6','#f9fafb','#ffffff'],
// ];
private const PALETTES = [
'mono' => [
'#6b7280','#9ca3af','#4b5563','#94a3b8','#64748b','#71717a',
'#a1a1aa','#737373','#9ca3af','#6b7280','#4b5563','#71717a'
],
'ocean' => [
'#0284c7','#0ea5e9','#38bdf8','#7dd3fc','#06b6d4','#22d3ee',
'#67e8f9','#0891b2','#0ea5e9','#38bdf8','#06b6d4','#22d3ee'
],
'forest' => [
'#166534','#15803d','#16a34a','#22c55e','#4ade80','#86efac',
'#65a30d','#84cc16','#4d7c0f','#22c55e','#16a34a','#15803d'
],
'sunset' => [
'#c2410c','#ea580c','#f97316','#fb923c','#fdba74','#f59e0b',
'#fbbf24','#facc15','#f97316','#fb923c','#ea580c','#f59e0b'
],
'slate' => [
'#334155','#475569','#64748b','#94a3b8','#cbd5e1','#94a3b8',
'#64748b','#475569','#334155','#64748b','#94a3b8','#475569'
],
'violet' => [
'#6d28d9','#7c3aed','#8b5cf6','#a78bfa','#c4b5fd','#9333ea',
'#a855f7','#c084fc','#8b5cf6','#7c3aed','#9333ea','#a855f7'
],
'rose' => [
'#be123c','#e11d48','#f43f5e','#fb7185','#fda4af','#f472b6',
'#ec4899','#f43f5e','#fb7185','#e11d48','#f472b6','#ec4899'
],
'teal' => [
'#0f766e','#0d9488','#14b8a6','#2dd4bf','#5eead4','#2dd4bf',
'#14b8a6','#0d9488','#0f766e','#2dd4bf','#14b8a6','#0d9488'
],
'amber' => [
'#b45309','#d97706','#f59e0b','#fbbf24','#fcd34d','#f59e0b',
'#fbbf24','#f59e0b','#d97706','#b45309','#f59e0b','#fbbf24'
],
'indigo' => [
'#3730a3','#4338ca','#4f46e5','#6366f1','#818cf8','#6366f1',
'#4f46e5','#4338ca','#3730a3','#6366f1','#4f46e5','#818cf8'
],
'cool_gray' => [
'#374151','#4b5563','#6b7280','#9ca3af','#d1d5db','#9ca3af',
'#6b7280','#4b5563','#374151','#9ca3af','#6b7280','#4b5563'
],
];
/**
* @param array<string, mixed> $chart Row from charts table
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
public function buildPayload(array $chart, array $rows): array
{
$display = $this->decodeDisplayConfig($chart['display_config'] ?? null);
$type = (string) ($chart['chart_type'] ?? 'line');
$hints = [
'number_format' => (string) ($display['number_format'] ?? 'auto'),
'decimal_places' => (int) ($display['decimal_places'] ?? 1),
];
if ($type === 'table') {
return array_merge($this->buildTablePayload($rows, $display), ['format_hints' => $hints]);
}
if ($type === 'kpi_card') {
return array_merge($this->buildKpiPayload($chart, $rows, $display), ['format_hints' => $hints]);
}
if ($type === 'ranked_progress') {
return array_merge($this->buildRankedProgressPayload($chart, $rows, $display), ['format_hints' => $hints]);
}
$options = $this->buildApexOptions($chart, $rows, $display);
if ($options === null) {
return [
'engine' => 'empty',
'message' => 'Not enough data to render this chart.',
'format_hints' => $hints,
];
}
return [
'engine' => 'apex',
'apexVersion' => 3,
'options' => $options,
'format_hints' => $hints,
];
}
/**
* @param array<string, mixed> $display
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>|null
*/
public function buildApexOptions(array $chart, array $rows, array $display): ?array
{
$type = (string) ($chart['chart_type'] ?? 'line');
if ($rows === [] || $type === 'table' || $type === 'kpi_card' || $type === 'ranked_progress') {
return null;
}
$x = trim((string) ($chart['x_field'] ?? ''));
$y = trim((string) ($chart['y_field'] ?? ''));
$group = trim((string) ($chart['group_field'] ?? ''));
$valueF = trim((string) ($chart['value_field'] ?? ''));
// Combo charts are designed as 2-metric single-series overlays; ignore grouping.
if ($type === 'combo') {
$group = '';
}
$colors = $this->resolveColors($display);
$baseFont = 'Inter, system-ui, sans-serif';
$titleText = (string) ($display['title'] ?? '');
$subtitleText = (string) ($display['subtitle'] ?? '');
$showGrid = array_key_exists('show_grid', $display) ? (bool) $display['show_grid'] : true;
$chartBase = [
'fontFamily' => $baseFont,
'background' => 'transparent',
'toolbar' => ['show' => false],
'animations' => ['enabled' => true, 'speed' => 400],
];
$grid = [
'borderColor' => 'rgba(148, 163, 184, 0.25)',
'strokeDashArray' => 4,
'xaxis' => ['lines' => ['show' => $showGrid]],
'yaxis' => ['lines' => ['show' => $showGrid]],
];
$legend = [
'show' => (bool) ($display['show_legend'] ?? true),
'position' => (string) ($display['legend_position'] ?? 'bottom'),
'labels' => ['colors' => '#64748b'],
'fontSize' => '12px',
];
$dataLabels = [
'enabled' => (bool) ($display['show_data_labels'] ?? false),
'style' => ['fontSize' => '11px', 'colors' => ['#fff']],
];
if ($type === 'pie' || $type === 'donut' || $type === 'polar_area') {
if ($x === '' || $y === '') {
return null;
}
$labels = [];
$series = [];
foreach ($rows as $r) {
$labels[] = $this->stringify($r[$x] ?? '');
$series[] = (float) $this->coerceNumber($r[$y] ?? 0);
}
if ($series === []) {
return null;
}
$pieChartType = match ($type) {
'donut' => 'donut',
'polar_area' => 'polarArea',
default => 'pie',
};
// Keep plotOptions as an object for Apex (not [] array) when no pie config is needed.
$plotOptions = new \stdClass();
if ($type === 'pie' || $type === 'donut') {
$plotOptions->pie = [
'donut' => [
'size' => $type === 'donut' ? '62%' : '0%',
'labels' => ['show' => $type === 'donut', 'name' => ['fontSize' => '13px'], 'value' => ['fontSize' => '22px', 'fontWeight' => 700]],
],
];
}
return [
'chart' => array_merge($chartBase, [
'type' => $pieChartType,
'height' => 360,
]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'subtitle' => $subtitleText !== '' ? ['text' => $subtitleText, 'align' => 'left', 'style' => ['fontSize' => '12px', 'color' => '#64748b']] : ['text' => ''],
'labels' => $labels,
'series' => $series,
'colors' => $this->ensureColorCount($colors, max(count($series), 1)),
'legend' => $legend,
'dataLabels' => $dataLabels,
'stroke' => ['width' => $type === 'polar_area' ? 1 : 0],
'plotOptions' => $plotOptions,
];
}
if ($type === 'scatter' || $type === 'bubble') {
if ($x === '' || $y === '') {
return null;
}
if ($type === 'bubble' && $valueF === '') {
return null;
}
$data = [];
foreach ($rows as $idx => $r) {
$xNum = $this->coercePlotNumber($this->rowField($r, $x));
$yNum = $this->coercePlotNumber($this->rowField($r, $y));
// Bubble charts are often switched from categorical charts in builder.
// When X is not numeric, use row index so points still render.
if ($type === 'bubble' && $xNum === null) {
$xNum = (float) ($idx + 1);
}
if ($xNum === null || $yNum === null) {
continue;
}
$pt = [
'x' => (float) $xNum,
'y' => (float) $yNum,
];
if ($type === 'bubble') {
$zRaw = $this->coercePlotNumber($this->rowField($r, $valueF));
$z = $zRaw !== null ? (float) $zRaw : 0.0;
$pt['z'] = $z > 0 ? $z : 1.0;
}
$data[] = $pt;
}
if ($data === []) {
return null;
}
$scatterType = $type === 'bubble' ? 'bubble' : 'scatter';
// Coordinate pairs need numeric axes; default category x-axis breaks scatter/bubble rendering.
$xaxisScatter = [
'type' => 'numeric',
'title' => ['text' => (string) ($display['x_axis_label'] ?? $x), 'style' => ['color' => '#64748b']],
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
'axisBorder' => ['show' => false],
'axisTicks' => ['show' => false],
];
$yaxisScatter = [
'type' => 'numeric',
'title' => ['text' => (string) ($display['y_axis_label'] ?? $y), 'style' => ['color' => '#64748b']],
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
];
$out = [
'chart' => array_merge($chartBase, ['type' => $scatterType, 'height' => 360, 'zoom' => ['enabled' => true, 'type' => 'xy']]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'series' => [['name' => $y, 'data' => $data]],
'colors' => [$colors[0]],
'grid' => $grid,
'xaxis' => $xaxisScatter,
'yaxis' => $yaxisScatter,
'tooltip' => ['theme' => 'light', 'shared' => false, 'intersect' => true],
];
if ($type === 'scatter') {
$out['markers'] = ['size' => 6];
} else {
$out['markers'] = ['opacity' => 0.85];
$out['fill'] = ['opacity' => 0.8];
$out['stroke'] = ['width' => 0];
$out['plotOptions'] = [
'bubble' => [
'minBubbleRadius' => 4,
'maxBubbleRadius' => 36,
],
];
}
return $out;
}
if ($type === 'radar') {
if ($x === '' || $y === '') {
return null;
}
$categories = [];
foreach ($rows as $r) {
$categories[] = $this->stringify($r[$x] ?? '');
}
$categories = array_values(array_unique($categories));
if ($group !== '') {
$seriesMap = [];
foreach ($rows as $r) {
$g = $this->stringify($r[$group] ?? 'Series');
$cat = $this->stringify($r[$x] ?? '');
$seriesMap[$g][$cat] = (float) $this->coerceNumber($r[$y] ?? 0);
}
$series = [];
foreach ($seriesMap as $name => $byCat) {
$data = [];
foreach ($categories as $c) {
$data[] = $byCat[$c] ?? 0;
}
$series[] = ['name' => $name, 'data' => $data];
}
} else {
$byCat = [];
foreach ($rows as $r) {
$cat = $this->stringify($r[$x] ?? '');
$byCat[$cat] = (float) $this->coerceNumber($r[$y] ?? 0);
}
$data = [];
foreach ($categories as $c) {
$data[] = $byCat[$c] ?? 0;
}
$series = [['name' => $y, 'data' => $data]];
}
return [
'chart' => array_merge($chartBase, ['type' => 'radar', 'height' => 400]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'series' => $series,
'xaxis' => ['categories' => $categories],
'stroke' => ['width' => 2],
'markers' => ['size' => 4],
'fill' => ['type' => 'solid', 'opacity' => 0.2],
'yaxis' => ['show' => false],
'dataLabels' => $dataLabels,
'colors' => $this->ensureColorCount($colors, max(count($series), 3)),
'legend' => $legend,
'tooltip' => ['theme' => 'light'],
];
}
if ($type === 'funnel') {
if ($x === '' || $y === '') {
return null;
}
$cats = [];
$vals = [];
foreach ($rows as $r) {
$cats[] = $this->stringify($r[$x] ?? '');
$vals[] = (float) $this->coerceNumber($r[$y] ?? 0);
}
return [
'chart' => array_merge($chartBase, ['type' => 'bar', 'height' => 360]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'series' => [['name' => $y, 'data' => $vals]],
'plotOptions' => [
'bar' => [
'horizontal' => true,
'isFunnel' => true,
'barHeight' => '75%',
'distributed' => true,
'borderRadius' => 6,
],
],
'colors' => $this->ensureColorCount($colors, max(count($vals), 1)),
'dataLabels' => array_merge($dataLabels, ['enabled' => true]),
'xaxis' => ['categories' => $cats, 'labels' => ['style' => ['colors' => '#64748b']]],
'yaxis' => ['labels' => ['style' => ['colors' => '#64748b']]],
'legend' => ['show' => false],
'grid' => $grid,
];
}
if ($type === 'gauge') {
$col = $valueF !== '' ? $valueF : $y;
if ($col === '') {
return null;
}
$val = (float) $this->coerceNumber($rows[array_key_last($rows)][$col] ?? 0);
$max = (float) ($display['gauge_max'] ?? 100);
if ($max <= 0) {
$max = 100;
}
$pct = min(100, max(0, ($val / $max) * 100));
return [
'chart' => array_merge($chartBase, ['type' => 'radialBar', 'height' => 360]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'series' => [round($pct, 1)],
'labels' => [$col],
'colors' => [$colors[0]],
'plotOptions' => [
'radialBar' => [
'startAngle' => -135,
'endAngle' => 135,
'hollow' => ['size' => '58%'],
'track' => ['background' => '#e2e8f0'],
'dataLabels' => [
'name' => ['fontSize' => '13px', 'color' => '#64748b', 'offsetY' => 72],
'value' => [
'fontSize' => '28px',
'fontWeight' => 700,
'color' => '#0f172a',
'offsetY' => -10,
],
],
],
],
];
}
if ($type === 'heatmap') {
if ($x === '' || $y === '' || $group === '') {
return null;
}
// Heatmap requires two different dimensions: columns (X) and rows (Group).
if ($x === $group) {
return null;
}
$seriesNameCol = $group !== '' ? $group : '__single__';
$matrix = $this->buildHeatmapMatrix($rows, $x, $seriesNameCol, $y);
if ($matrix === null) {
return null;
}
return [
'chart' => array_merge($chartBase, ['type' => 'heatmap', 'height' => 380]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'series' => $matrix['series'],
'dataLabels' => ['enabled' => false],
'colors' => [$colors[0]],
'xaxis' => ['type' => 'category', 'categories' => $matrix['xCategories'], 'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']]],
'plotOptions' => [
'heatmap' => [
'shadeIntensity' => 0.45,
'radius' => 3,
'colorScale' => [
'ranges' => [
['from' => $matrix['min'], 'to' => $matrix['mid'], 'color' => $colors[3]],
['from' => $matrix['mid'], 'to' => $matrix['max'], 'color' => $colors[0]],
],
],
],
],
'tooltip' => ['theme' => 'light'],
];
}
if ($x === '' || $y === '') {
return null;
}
$horizontal = ! empty($display['horizontal_bar']);
if ($type === 'spline') {
$curve = 'smooth';
} elseif ($type === 'stepline') {
$curve = 'stepline';
} else {
$curve = 'smooth';
if (isset($display['smooth']) && ! $display['smooth']) {
$curve = 'straight';
}
if (! empty($display['stepline'])) {
$curve = 'stepline';
}
}
$categories = [];
foreach ($rows as $r) {
$categories[] = $this->stringify($r[$x] ?? '');
}
$categories = array_values(array_unique($categories));
if ($group !== '') {
$seriesMap = [];
foreach ($rows as $r) {
$g = $this->stringify($r[$group] ?? 'Series');
$cat = $this->stringify($r[$x] ?? '');
$seriesMap[$g][$cat] = (float) $this->coerceNumber($r[$y] ?? 0);
}
$series = [];
foreach ($seriesMap as $name => $byCat) {
$data = [];
foreach ($categories as $c) {
$data[] = $byCat[$c] ?? 0;
}
$series[] = ['name' => $name, 'data' => $data];
}
} else {
$byCat = [];
foreach ($rows as $r) {
$cat = $this->stringify($r[$x] ?? '');
$byCat[$cat] = (float) $this->coerceNumber($r[$y] ?? 0);
}
$data = [];
foreach ($categories as $c) {
$data[] = $byCat[$c] ?? 0;
}
$series = [['name' => $y, 'data' => $data]];
}
$stacked = ! empty($display['stacked']);
if ($type === 'combo') {
$y2 = trim((string) ($display['secondary_y_field'] ?? ''));
if ($y2 === '') {
return null;
}
$lineData = $this->columnValues($rows, $categories, $x, $y2, '');
$barSeries = array_merge($series[0], ['type' => 'column']);
return [
'chart' => array_merge($chartBase, ['type' => 'line', 'height' => 380]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'stroke' => ['width' => [0, 3], 'curve' => [$curve, $curve]],
'series' => [
$barSeries,
['name' => $y2, 'type' => 'line', 'data' => $lineData],
],
'xaxis' => [
'categories' => $categories,
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
'axisBorder' => ['show' => false],
'axisTicks' => ['show' => false],
'title' => ['text' => (string) ($display['x_axis_label'] ?? '')],
],
'yaxis' => $this->buildYAxis($display, $y, false),
'plotOptions' => ['bar' => ['columnWidth' => '55%', 'borderRadius' => 5]],
'colors' => $this->ensureColorCount($colors, 4),
'grid' => $grid,
'legend' => $legend,
'fill' => ['type' => 'solid', 'opacity' => [1, 1]],
'tooltip' => $this->sharedTooltip(),
];
}
$apexType = $type === 'bar' ? 'bar' : ($type === 'area' ? 'area' : 'line');
// Must be an object with `type` — never [] (JSON []). Apex reads fill.type[i] in sameValueSeriesFix;
// undefined[i] throws "Cannot read properties of undefined (reading '0')".
$fill = ['type' => 'solid', 'opacity' => 1];
if ($type === 'area') {
$fill = [
'type' => 'gradient',
'gradient' => ['shadeIntensity' => 0.35, 'opacityFrom' => 0.45, 'opacityTo' => 0.05, 'stops' => [0, 90, 100]],
];
}
$plotBar = [
'horizontal' => $horizontal,
'borderRadius' => $horizontal ? 0 : 5,
'columnWidth' => '58%',
];
if ($stacked && $apexType === 'bar') {
$plotBar['stacked'] = true;
}
return [
'chart' => array_merge($chartBase, [
'type' => $apexType,
'height' => 380,
'stacked' => $stacked,
]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'subtitle' => $subtitleText !== '' ? ['text' => $subtitleText, 'align' => 'left', 'style' => ['fontSize' => '12px', 'color' => '#64748b']] : ['text' => ''],
'series' => $series,
'xaxis' => [
'categories' => $categories,
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
'axisBorder' => ['show' => false],
'axisTicks' => ['show' => false],
'title' => ['text' => (string) ($display['x_axis_label'] ?? '')],
],
'yaxis' => $this->buildYAxis($display, $y, true),
'stroke' => ['curve' => $curve, 'width' => $type === 'bar' ? 0 : 2.5],
'markers' => ['size' => in_array($type, ['line', 'area', 'spline', 'stepline'], true) ? 0 : 4],
'dataLabels' => $dataLabels,
'colors' => $this->ensureColorCount($colors, max(count($series), 3)),
'grid' => $grid,
'legend' => $legend,
'plotOptions' => ['bar' => $plotBar],
'fill' => $fill,
'tooltip' => $apexType === 'bar' ? $this->pointTooltip() : $this->sharedTooltip(),
];
}
/**
* Bar charts default tooltip.intersect=true, which conflicts with shared tooltips in ApexCharts.
*
* @return array<string, mixed>
*/
private function sharedTooltip(): array
{
return [
'shared' => true,
'intersect' => false,
'theme' => 'light',
];
}
/**
* Show only hovered datapoint in tooltip (used for bar charts).
*
* @return array<string, mixed>
*/
private function pointTooltip(): array
{
return [
'shared' => false,
'intersect' => true,
'theme' => 'light',
];
}
/**
* @param array<string, mixed> $display
* @return array<string, mixed>
*/
private function buildYAxis(array $display, string $yTitle, bool $includeFontSize): array
{
$yaxisOpt = [
'title' => ['text' => (string) ($display['y_axis_label'] ?? $yTitle), 'style' => ['color' => '#64748b']],
'labels' => ['style' => ['colors' => '#64748b']],
];
if ($includeFontSize) {
$yaxisOpt['labels']['style']['fontSize'] = '11px';
}
if (isset($display['y_min']) && $display['y_min'] !== '' && is_numeric($display['y_min'])) {
$yaxisOpt['min'] = (float) $display['y_min'];
}
if (isset($display['y_max']) && $display['y_max'] !== '' && is_numeric($display['y_max'])) {
$yaxisOpt['max'] = (float) $display['y_max'];
}
return $yaxisOpt;
}
/**
* Horizontal ranked progress bars: category (X) + numeric value (Y), aggregated per label.
*
* @param array<string, mixed> $chart
* @param array<int, array<string, mixed>> $rows
* @param array<string, mixed> $display
* @return array<string, mixed>
*/
private function buildRankedProgressPayload(array $chart, array $rows, array $display): array
{
$x = trim((string) ($chart['x_field'] ?? ''));
$y = trim((string) ($chart['y_field'] ?? ''));
if ($x === '' || $y === '' || $rows === []) {
return [
'engine' => 'empty',
'message' => 'Ranked progress needs X (category) and Y (numeric value) fields.',
];
}
$aggregates = [];
foreach ($rows as $r) {
$label = $this->stringify($this->rowField($r, $x));
if ($label === '') {
continue;
}
$aggregates[$label] = ($aggregates[$label] ?? 0.0) + (float) $this->coerceNumber($this->rowField($r, $y));
}
if ($aggregates === []) {
return [
'engine' => 'empty',
'message' => 'No data to render this chart.',
];
}
arsort($aggregates, SORT_NUMERIC);
$limit = isset($display['ranked_progress_limit']) ? max(1, min(200, (int) $display['ranked_progress_limit'])) : 50;
$aggregates = array_slice($aggregates, 0, $limit, true);
$maxVal = max($aggregates);
$total = array_sum($aggregates);
$total = abs($total) < 1e-12 ? 0.0 : $total;
$palette = $this->ensureColorCount($this->resolveColors($display), max(count($aggregates), 1));
$items = [];
$i = 0;
foreach ($aggregates as $label => $val) {
$color = $palette[$i % count($palette)];
$barPct = $maxVal > 0 ? round(($val / $maxVal) * 100, 4) : 0.0;
$items[] = [
'label' => $label,
'value' => $val,
'value_label' => $this->formatNumber($val, $display),
'color' => $color,
'bar_pct' => $barPct,
];
$i++;
}
$footerCount = isset($display['ranked_progress_footer_count']) ? max(0, min(5, (int) $display['ranked_progress_footer_count'])) : 2;
$footer = [];
if ($footerCount > 0 && $total > 0) {
foreach (array_slice($items, 0, $footerCount) as $row) {
$footer[] = [
'label' => $row['label'],
'pct' => (int) round(($row['value'] / $total) * 100),
'color' => $row['color'],
];
}
}
return [
'engine' => 'ranked_progress',
'title' => (string) ($display['title'] ?? ''),
'subtitle' => (string) ($display['subtitle'] ?? ''),
'items' => $items,
'footer' => $footer,
];
}
/**
* @param array<string, mixed> $display
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
private function buildTablePayload(array $rows, array $display): array
{
$columns = $rows !== [] ? array_keys($rows[0]) : [];
$limit = isset($display['table_preview_limit']) ? (int) $display['table_preview_limit'] : 500;
return [
'engine' => 'table',
'columns' => $columns,
'rows' => array_slice($rows, 0, max(1, $limit)),
];
}
/**
* @param array<string, mixed> $display
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
private function buildKpiPayload(array $chart, array $rows, array $display): array
{
$cols = $display['kpi_columns'] ?? null;
if (! is_array($cols) || $cols === []) {
$y = trim((string) ($chart['y_field'] ?? ''));
$cols = $y !== '' ? [$y] : [];
}
if ($cols === [] || $rows === []) {
return ['engine' => 'kpi', 'cards' => []];
}
$row = $rows[array_key_last($rows)];
$cards = [];
foreach ($cols as $c) {
$c = trim((string) $c);
if ($c === '') {
continue;
}
$raw = $row[$c] ?? null;
$cards[] = [
'label' => $c,
'value' => $this->formatNumber($this->coerceNumber($raw), $display),
'raw' => $raw,
];
if (count($cards) >= 6) {
break;
}
}
return ['engine' => 'kpi', 'cards' => $cards];
}
/**
* @return array{series: array<int, mixed>, xCategories: array<int, string>, min: float, max: float, mid: float}|null
*/
private function buildHeatmapMatrix(array $rows, string $xCol, string $seriesCol, string $valueCol): ?array
{
$xCats = [];
$sCats = [];
foreach ($rows as $r) {
$xCats[$this->stringify($r[$xCol] ?? '')] = true;
if ($seriesCol === '__single__') {
$sCats['Value'] = true;
} else {
$sCats[$this->stringify($r[$seriesCol] ?? 'Series')] = true;
}
}
$xCategories = array_keys($xCats);
$seriesNames = array_keys($sCats);
if ($xCategories === [] || $seriesNames === []) {
return null;
}
$lookup = [];
$min = PHP_FLOAT_MAX;
$max = -PHP_FLOAT_MAX;
foreach ($rows as $r) {
$xc = $this->stringify($r[$xCol] ?? '');
$sn = $seriesCol === '__single__' ? 'Value' : $this->stringify($r[$seriesCol] ?? 'Series');
$v = (float) $this->coerceNumber($r[$valueCol] ?? 0);
$lookup[$sn][$xc] = $v;
$min = min($min, $v);
$max = max($max, $v);
}
if ($min === PHP_FLOAT_MAX) {
$min = 0;
$max = 1;
}
$mid = $min + ($max - $min) / 2;
$series = [];
foreach ($seriesNames as $name) {
$data = [];
foreach ($xCategories as $xc) {
$data[] = ['x' => $xc, 'y' => $lookup[$name][$xc] ?? 0];
}
$series[] = ['name' => $name, 'data' => $data];
}
return ['series' => $series, 'xCategories' => $xCategories, 'min' => $min, 'max' => $max, 'mid' => $mid];
}
/**
* @return array<int, float>
*/
private function columnValues(array $rows, array $categories, string $xCol, string $yCol, string $group): array
{
if ($group !== '') {
return array_fill(0, count($categories), 0);
}
$byCat = [];
foreach ($rows as $r) {
$byCat[$this->stringify($r[$xCol] ?? '')] = (float) $this->coerceNumber($r[$yCol] ?? 0);
}
$out = [];
foreach ($categories as $c) {
$out[] = $byCat[$c] ?? 0;
}
return $out;
}
/**
* @param array<string, mixed> $display
* @return array<int, string>
*/
private function resolveColors(array $display): array
{
$mode = (string) ($display['color_mode'] ?? 'preset');
$custom = $display['palette_colors'] ?? null;
$mono = self::PALETTES['mono'];
if ($mode === 'custom' && is_array($custom) && $custom !== []) {
$hex = [];
foreach ($custom as $c) {
$s = is_scalar($c) ? trim((string) $c) : '';
if ($s !== '' && preg_match('/^#[0-9A-Fa-f]{6}$/', $s)) {
$hex[] = $s;
}
}
if ($hex !== []) {
return array_values($hex);
}
return $mono;
}
$name = (string) ($display['palette'] ?? 'mono');
$pal = self::PALETTES[$name] ?? $mono;
// Backward compatibility: use old palette_colors only if palette name is missing.
if (! isset($display['palette']) && is_array($custom) && $custom !== []) {
$fromLegacy = [];
foreach ($custom as $c) {
$s = is_scalar($c) ? trim((string) $c) : '';
if ($s !== '' && preg_match('/^#[0-9A-Fa-f]{6}$/', $s)) {
$fromLegacy[] = $s;
}
}
if ($fromLegacy !== []) {
return $fromLegacy;
}
}
return $pal !== [] ? $pal : $mono;
}
/**
* Extend a base palette to provide enough visually distinct colors.
*
* @param array<int, string> $base
* @return array<int, string>
*/
private function ensureColorCount(array $base, int $count): array
{
$count = max(1, $count);
$colors = [];
foreach ($base as $c) {
if (is_string($c) && preg_match('/^#[0-9A-Fa-f]{6}$/', $c)) {
$colors[] = strtolower($c);
}
}
if ($colors === []) {
$colors = self::PALETTES['ocean'];
}
if (count($colors) >= $count) {
return array_slice($colors, 0, $count);
}
// Golden-angle hue steps avoid near-neighbor collisions when many series exist.
for ($i = count($colors); $i < $count; $i++) {
$hue = fmod(212.0 + ($i * 137.508), 360.0);
$sat = 62.0 + (($i % 3) * 6.0);
$light = 52.0 - (($i % 4) * 4.0);
$colors[] = $this->hslToHex($hue, min(78.0, $sat), max(36.0, $light));
}
return $colors;
}
private function hslToHex(float $h, float $s, float $l): string
{
$h = fmod(max(0.0, $h), 360.0);
$s = max(0.0, min(100.0, $s)) / 100.0;
$l = max(0.0, min(100.0, $l)) / 100.0;
$c = (1.0 - abs((2.0 * $l) - 1.0)) * $s;
$hp = $h / 60.0;
$x = $c * (1.0 - abs(fmod($hp, 2.0) - 1.0));
$r1 = 0.0;
$g1 = 0.0;
$b1 = 0.0;
if ($hp >= 0.0 && $hp < 1.0) {
$r1 = $c; $g1 = $x; $b1 = 0.0;
} elseif ($hp < 2.0) {
$r1 = $x; $g1 = $c; $b1 = 0.0;
} elseif ($hp < 3.0) {
$r1 = 0.0; $g1 = $c; $b1 = $x;
} elseif ($hp < 4.0) {
$r1 = 0.0; $g1 = $x; $b1 = $c;
} elseif ($hp < 5.0) {
$r1 = $x; $g1 = 0.0; $b1 = $c;
} else {
$r1 = $c; $g1 = 0.0; $b1 = $x;
}
$m = $l - ($c / 2.0);
$r = (int) round(($r1 + $m) * 255);
$g = (int) round(($g1 + $m) * 255);
$b = (int) round(($b1 + $m) * 255);
return sprintf('#%02x%02x%02x', max(0, min(255, $r)), max(0, min(255, $g)), max(0, min(255, $b)));
}
/**
* @return array<string, mixed>
*/
private function decodeDisplayConfig(mixed $json): array
{
if ($json === null || $json === '') {
return [];
}
if (is_array($json)) {
return $json;
}
$d = json_decode((string) $json, true);
return is_array($d) ? $d : [];
}
private function stringify(mixed $v): string
{
if ($v === null) {
return '';
}
if (is_scalar($v)) {
return (string) $v;
}
return json_encode($v) ?: '';
}
private function coerceNumber(mixed $v): float|int
{
if (is_int($v) || is_float($v)) {
return $v;
}
if (is_string($v)) {
$n = str_replace([',', ' '], '', $v);
return is_numeric($n) ? 0 + $n : 0;
}
return 0;
}
private function coercePlotNumber(mixed $v): ?float
{
if (is_int($v) || is_float($v)) {
return (float) $v;
}
if (is_string($v)) {
$n = str_replace([',', ' '], '', $v);
if (is_numeric($n)) {
return (float) (0 + $n);
}
}
return null;
}
/**
* @param array<string, mixed>|object $row
*/
private function rowField(mixed $row, string $key): mixed
{
if ($key === '') {
return null;
}
if (is_array($row)) {
return $row[$key] ?? null;
}
if (is_object($row)) {
return $row->{$key} ?? null;
}
return null;
}
/**
* @param array<string, mixed> $display
*/
private function formatNumber(float|int $n, array $display): string
{
$fmt = (string) ($display['number_format'] ?? 'auto');
$dec = isset($display['decimal_places']) ? (int) $display['decimal_places'] : 1;
return match ($fmt) {
'inr' => '₹' . number_format((float) $n, $dec),
'usd' => '$' . number_format((float) $n, $dec),
'percent' => number_format((float) $n, $dec) . '%',
'compact' => $this->compactNumber((float) $n, $dec),
default => number_format((float) $n, $dec),
};
}
private function compactNumber(float $n, int $dec): string
{
$abs = abs($n);
if ($abs >= 1_000_000_000) {
return round($n / 1_000_000_000, $dec) . 'B';
}
if ($abs >= 1_000_000) {
return round($n / 1_000_000, $dec) . 'M';
}
if ($abs >= 1_000) {
return round($n / 1_000, $dec) . 'K';
}
return number_format($n, $dec);
}
}