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.
|
// Keep plotOptions as an object for Apex (not [] array) when no pie config is needed.
|
||||||
$plotOptions = new \stdClass();
|
$plotOptions = new \stdClass();
|
||||||
if ($type === 'pie' || $type === 'donut') {
|
if ($type === 'pie' || $type === 'donut') {
|
||||||
$plotOptions['pie'] = [
|
$plotOptions->pie = [
|
||||||
'donut' => [
|
'donut' => [
|
||||||
'size' => $type === 'donut' ? '62%' : '0%',
|
'size' => $type === 'donut' ? '62%' : '0%',
|
||||||
'labels' => ['show' => $type === 'donut', 'name' => ['fontSize' => '13px'], 'value' => ['fontSize' => '22px', 'fontWeight' => 700]],
|
'labels' => ['show' => $type === 'donut', 'name' => ['fontSize' => '13px'], 'value' => ['fontSize' => '22px', 'fontWeight' => 700]],
|
||||||
@ -251,17 +251,34 @@ class ChartRenderer
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$data = [];
|
$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 = [
|
$pt = [
|
||||||
'x' => (float) $this->coerceNumber($r[$x] ?? null),
|
'x' => (float) $xNum,
|
||||||
'y' => (float) $this->coerceNumber($r[$y] ?? null),
|
'y' => (float) $yNum,
|
||||||
];
|
];
|
||||||
if ($type === 'bubble') {
|
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;
|
$pt['z'] = $z > 0 ? $z : 1.0;
|
||||||
}
|
}
|
||||||
$data[] = $pt;
|
$data[] = $pt;
|
||||||
}
|
}
|
||||||
|
if ($data === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$scatterType = $type === 'bubble' ? 'bubble' : 'scatter';
|
$scatterType = $type === 'bubble' ? 'bubble' : 'scatter';
|
||||||
|
|
||||||
@ -415,6 +432,10 @@ class ChartRenderer
|
|||||||
if ($x === '' || $y === '' || $group === '') {
|
if ($x === '' || $y === '' || $group === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Heatmap requires two different dimensions: columns (X) and rows (Group).
|
||||||
|
if ($x === $group) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$seriesNameCol = $group !== '' ? $group : '__single__';
|
$seriesNameCol = $group !== '' ? $group : '__single__';
|
||||||
$matrix = $this->buildHeatmapMatrix($rows, $x, $seriesNameCol, $y);
|
$matrix = $this->buildHeatmapMatrix($rows, $x, $seriesNameCol, $y);
|
||||||
if ($matrix === null) {
|
if ($matrix === null) {
|
||||||
@ -912,6 +933,21 @@ class ChartRenderer
|
|||||||
return 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> $display
|
* @param array<string, mixed> $display
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -5,6 +5,40 @@
|
|||||||
const URL_PREVIEW_RENDER = '<?= base_url('chart/preview-render') ?>';
|
const URL_PREVIEW_RENDER = '<?= base_url('chart/preview-render') ?>';
|
||||||
const URL_VARS = (id) => '<?= base_url('chart/saved-query/') ?>' + id + '/variables';
|
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) {
|
function formatAxisNumber(val, fmt, dec) {
|
||||||
const n = Number(val);
|
const n = Number(val);
|
||||||
if (Number.isNaN(n)) return val;
|
if (Number.isNaN(n)) return val;
|
||||||
@ -102,16 +136,95 @@
|
|||||||
if (payload.engine === 'table') {
|
if (payload.engine === 'table') {
|
||||||
const cols = payload.columns || [];
|
const cols = payload.columns || [];
|
||||||
const rows = payload.rows || [];
|
const rows = payload.rows || [];
|
||||||
let html = '<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr>';
|
const state = { q: '', page: 1, perPage: 10 };
|
||||||
cols.forEach((c) => { html += '<th>' + String(c) + '</th>'; });
|
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>';
|
html += '</tr></thead><tbody>';
|
||||||
rows.forEach((r) => {
|
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>';
|
html += '<tr>';
|
||||||
cols.forEach((c) => { html += '<td>' + (r[c] != null ? String(r[c]) : '') + '</td>'; });
|
cols.forEach((c) => { html += '<td>' + escapeHtml(r && r[c] != null ? String(r[c]) : '') + '</td>'; });
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
html += '</tbody></table></div>';
|
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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.engine === 'kpi') {
|
if (payload.engine === 'kpi') {
|
||||||
@ -409,7 +522,7 @@
|
|||||||
canRenderPreview() {
|
canRenderPreview() {
|
||||||
if (!this.savedQueryId || !this.columns.length) return false;
|
if (!this.savedQueryId || !this.columns.length) return false;
|
||||||
if (this.chartType === 'table') return true;
|
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 === 'gauge' || this.chartType === 'kpi_card') return !!(this.valueField || this.yField);
|
||||||
if (this.chartType === 'bubble') return !!(this.xField && this.yField && this.valueField);
|
if (this.chartType === 'bubble') return !!(this.xField && this.yField && this.valueField);
|
||||||
if (this.chartType === 'combo') return !!(this.xField && this.yField);
|
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).',
|
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.',
|
radar: 'Radar: X = axis labels, Y = values. Optional group for multiple polygons.',
|
||||||
funnel: 'Funnel: X = stage names, Y = amounts.',
|
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).',
|
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.',
|
kpi_card: 'KPI: pick a value column (or Y) for the headline number.',
|
||||||
table: 'Table: shows all columns; no field mapping required.',
|
table: 'Table: shows all columns; no field mapping required.',
|
||||||
@ -538,6 +651,14 @@
|
|||||||
}
|
}
|
||||||
return;
|
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) {
|
if (this.chartType === 'combo' && !this.display.secondary_y_field) {
|
||||||
this.display.secondary_y_field = this.yField || '';
|
this.display.secondary_y_field = this.yField || '';
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user