1109 lines
40 KiB
PHP
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);
|
|
}
|
|
}
|