UI improvement
This commit is contained in:
parent
3fbe6f52e7
commit
912042e054
@ -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
|
||||
*/
|
||||
|
||||
@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 || '';
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user