diff --git a/app/Libraries/ChartRenderer.php b/app/Libraries/ChartRenderer.php index c540ef8..5954fe6 100644 --- a/app/Libraries/ChartRenderer.php +++ b/app/Libraries/ChartRenderer.php @@ -218,7 +218,7 @@ class ChartRenderer // 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'] = [ + $plotOptions->pie = [ 'donut' => [ 'size' => $type === 'donut' ? '62%' : '0%', 'labels' => ['show' => $type === 'donut', 'name' => ['fontSize' => '13px'], 'value' => ['fontSize' => '22px', 'fontWeight' => 700]], @@ -251,17 +251,34 @@ class ChartRenderer return null; } $data = []; - foreach ($rows as $r) { + foreach ($rows as $idx => $r) { + $xNum = $this->coercePlotNumber($r[$x] ?? null); + $yNum = $this->coercePlotNumber($r[$y] ?? null); + + // 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) $this->coerceNumber($r[$x] ?? null), - 'y' => (float) $this->coerceNumber($r[$y] ?? null), + 'x' => (float) $xNum, + 'y' => (float) $yNum, ]; if ($type === 'bubble') { - $z = (float) $this->coerceNumber($r[$valueF] ?? 0); + $zRaw = $this->coercePlotNumber($r[$valueF] ?? null); + $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'; @@ -415,6 +432,10 @@ class ChartRenderer 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) { @@ -912,6 +933,21 @@ class ChartRenderer 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 $display */ diff --git a/app/Views/chart/builder_scripts.php b/app/Views/chart/builder_scripts.php index fafe438..8ab71e2 100644 --- a/app/Views/chart/builder_scripts.php +++ b/app/Views/chart/builder_scripts.php @@ -5,6 +5,40 @@ const URL_PREVIEW_RENDER = ''; const URL_VARS = (id) => '' + id + '/variables'; + function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + function prettyHeaderName(raw) { + const t = String(raw ?? '').trim(); + if (!t) return ''; + return t + .replace(/[_\-]+/g, ' ') + .replace(/\s+/g, ' ') + .replace(/\b\w/g, (m) => m.toUpperCase()); + } + + function exportRowsAsCsv(filename, cols, rows) { + const esc = (v) => { + const s = String(v ?? ''); + if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; + return s; + }; + const lines = []; + lines.push(cols.map((c) => esc(prettyHeaderName(c))).join(',')); + rows.forEach((r) => { + lines.push(cols.map((c) => esc(r && r[c] != null ? r[c] : '')).join(',')); + }); + const blob = new Blob(["\uFEFF" + lines.join('\n')], { type: 'text/csv;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + } + function formatAxisNumber(val, fmt, dec) { const n = Number(val); if (Number.isNaN(n)) return val; @@ -102,16 +136,95 @@ if (payload.engine === 'table') { const cols = payload.columns || []; const rows = payload.rows || []; - let html = '
'; - cols.forEach((c) => { html += ''; }); - html += ''; - rows.forEach((r) => { - html += ''; - cols.forEach((c) => { html += ''; }); - html += ''; - }); - html += '
' + String(c) + '
' + (r[c] != null ? String(r[c]) : '') + '
'; - el.innerHTML = html; + const state = { q: '', page: 1, perPage: 10 }; + const lower = (s) => String(s ?? '').toLowerCase(); + + const buildFiltered = () => { + if (!state.q) return rows.slice(); + const q = lower(state.q); + return rows.filter((r) => cols.some((c) => lower(r && r[c] != null ? r[c] : '').includes(q))); + }; + + const render = () => { + const filtered = buildFiltered(); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / state.perPage)); + if (state.page > totalPages) state.page = totalPages; + if (state.page < 1) state.page = 1; + const start = (state.page - 1) * state.perPage; + const paged = filtered.slice(start, start + state.perPage); + + let html = '
'; + html += '
'; + html += ''; + html += ''; + html += 'rows / page'; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + html += '
'; + cols.forEach((c) => { html += ''; }); + html += ''; + if (!paged.length) { + html += ''; + } else { + paged.forEach((r) => { + html += ''; + cols.forEach((c) => { html += ''; }); + html += ''; + }); + } + html += '
' + escapeHtml(prettyHeaderName(c)) + '
No matching rows.
' + escapeHtml(r && r[c] != null ? String(r[c]) : '') + '
'; + + const from = total === 0 ? 0 : start + 1; + const to = Math.min(start + state.perPage, total); + html += '
'; + html += 'Showing ' + from + ' to ' + to + ' of ' + total + ' entries'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + + el.innerHTML = html; + + const searchEl = el.querySelector('[data-cb-table-search]'); + if (searchEl) { + searchEl.addEventListener('input', (ev) => { + state.q = ev.target.value || ''; + state.page = 1; + render(); + }); + } + const perPageEl = el.querySelector('[data-cb-table-per-page]'); + if (perPageEl) { + perPageEl.addEventListener('change', (ev) => { + const next = Number(ev.target.value || 10); + state.perPage = [10, 25, 50, 100].includes(next) ? next : 10; + state.page = 1; + render(); + }); + } + const prevEl = el.querySelector('[data-cb-table-page="prev"]'); + if (prevEl) prevEl.addEventListener('click', () => { state.page -= 1; render(); }); + const nextEl = el.querySelector('[data-cb-table-page="next"]'); + if (nextEl) nextEl.addEventListener('click', () => { state.page += 1; render(); }); + + const csvBtn = el.querySelector('[data-cb-table-export="csv"]'); + if (csvBtn) csvBtn.addEventListener('click', () => exportRowsAsCsv('table-preview.csv', cols, filtered)); + const excelBtn = el.querySelector('[data-cb-table-export="excel"]'); + if (excelBtn) excelBtn.addEventListener('click', () => exportRowsAsCsv('table-preview-excel.csv', cols, filtered)); + }; + + render(); return; } if (payload.engine === 'kpi') { @@ -409,7 +522,7 @@ canRenderPreview() { if (!this.savedQueryId || !this.columns.length) return false; if (this.chartType === 'table') return true; - if (this.chartType === 'heatmap') return !!(this.xField && this.yField && this.groupField); + if (this.chartType === 'heatmap') return !!(this.xField && this.yField && this.groupField && this.xField !== this.groupField); if (this.chartType === 'gauge' || this.chartType === 'kpi_card') return !!(this.valueField || this.yField); if (this.chartType === 'bubble') return !!(this.xField && this.yField && this.valueField); if (this.chartType === 'combo') return !!(this.xField && this.yField); @@ -453,7 +566,7 @@ bubble: 'Bubble: X and Y numeric; pick a third column for bubble size (Z).', radar: 'Radar: X = axis labels, Y = values. Optional group for multiple polygons.', funnel: 'Funnel: X = stage names, Y = amounts.', - heatmap: 'Heatmap: X = columns, Group = rows (series), Y = cell value.', + heatmap: 'Heatmap: X = columns, Group = rows (series), Y = cell value. X and Group must be different fields.', gauge: 'Gauge: Value column compared to max (set in display / defaults to 100).', kpi_card: 'KPI: pick a value column (or Y) for the headline number.', table: 'Table: shows all columns; no field mapping required.', @@ -538,6 +651,14 @@ } return; } + if (this.chartType === 'heatmap' && this.xField && this.groupField && this.xField === this.groupField) { + this.renderError = 'Heatmap needs different fields for X axis and Group / series.'; + this.renderMeta = ''; + if (el) { + el.innerHTML = '
Pick different columns for X axis and Group / series.
'; + } + return; + } if (this.chartType === 'combo' && !this.display.secondary_y_field) { this.display.secondary_y_field = this.yField || ''; }