> */ // 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 $chart Row from charts table * @param array> $rows * @return array */ 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 $display * @param array> $rows * @return array|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 */ private function sharedTooltip(): array { return [ 'shared' => true, 'intersect' => false, 'theme' => 'light', ]; } /** * Show only hovered datapoint in tooltip (used for bar charts). * * @return array */ private function pointTooltip(): array { return [ 'shared' => false, 'intersect' => true, 'theme' => 'light', ]; } /** * @param array $display * @return array */ 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 $chart * @param array> $rows * @param array $display * @return array */ 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 $display * @param array> $rows * @return array */ 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 $display * @param array> $rows * @return array */ 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, xCategories: array, 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 */ 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 $display * @return array */ 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 $base * @return array */ 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 */ 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|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 $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); } }