UI improvement

This commit is contained in:
Gowtham M 2026-04-09 12:11:53 +05:30
parent 3fbe6f52e7
commit 912042e054
2 changed files with 174 additions and 17 deletions

View File

@ -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<string, mixed> $display
*/

View File

@ -5,6 +5,40 @@
const URL_PREVIEW_RENDER = '<?= base_url('chart/preview-render') ?>';
const URL_VARS = (id) => '<?= base_url('chart/saved-query/') ?>' + id + '/variables';
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = '<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr>';
cols.forEach((c) => { html += '<th>' + String(c) + '</th>'; });
html += '</tr></thead><tbody>';
rows.forEach((r) => {
html += '<tr>';
cols.forEach((c) => { html += '<td>' + (r[c] != null ? String(r[c]) : '') + '</td>'; });
html += '</tr>';
});
html += '</tbody></table></div>';
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 = '<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-2">';
html += '<div class="d-flex gap-2 align-items-center">';
html += '<input type="search" class="form-control form-control-sm" style="min-width:220px;" placeholder="Search..." data-cb-table-search value="' + escapeHtml(state.q) + '">';
html += '<select class="form-select form-select-sm" style="width:86px;" data-cb-table-per-page>';
[10, 25, 50, 100].forEach((n) => {
html += '<option value="' + n + '"' + (state.perPage === n ? ' selected' : '') + '>' + n + '</option>';
});
html += '</select>';
html += '<span class="small text-muted">rows / page</span>';
html += '</div>';
html += '<div class="d-flex gap-2 align-items-center">';
html += '<button type="button" class="btn btn-outline-secondary btn-sm" data-cb-table-export="csv">Export CSV</button>';
html += '<button type="button" class="btn btn-outline-secondary btn-sm" data-cb-table-export="excel">Export Excel</button>';
html += '</div>';
html += '</div>';
html += '<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr>';
cols.forEach((c) => { html += '<th>' + escapeHtml(prettyHeaderName(c)) + '</th>'; });
html += '</tr></thead><tbody>';
if (!paged.length) {
html += '<tr><td class="text-muted small" colspan="' + Math.max(cols.length, 1) + '">No matching rows.</td></tr>';
} else {
paged.forEach((r) => {
html += '<tr>';
cols.forEach((c) => { html += '<td>' + escapeHtml(r && r[c] != null ? String(r[c]) : '') + '</td>'; });
html += '</tr>';
});
}
html += '</tbody></table></div>';
const from = total === 0 ? 0 : start + 1;
const to = Math.min(start + state.perPage, total);
html += '<div class="d-flex align-items-center justify-content-between mt-2">';
html += '<span class="small text-muted">Showing ' + from + ' to ' + to + ' of ' + total + ' entries</span>';
html += '<div class="btn-group btn-group-sm">';
html += '<button type="button" class="btn btn-outline-secondary" data-cb-table-page="prev"' + (state.page <= 1 ? ' disabled' : '') + '>Prev</button>';
html += '<button type="button" class="btn btn-outline-secondary disabled">Page ' + state.page + ' / ' + totalPages + '</button>';
html += '<button type="button" class="btn btn-outline-secondary" data-cb-table-page="next"' + (state.page >= totalPages ? ' disabled' : '') + '>Next</button>';
html += '</div></div>';
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 = '<div class="d-flex align-items-center justify-content-center h-100 text-muted small p-4">Pick different columns for X axis and Group / series.</div>';
}
return;
}
if (this.chartType === 'combo' && !this.display.secondary_y_field) {
this.display.secondary_y_field = this.yField || '';
}