757 lines
37 KiB
PHP
757 lines
37 KiB
PHP
<script>
|
|
(function () {
|
|
const CB_CSRF = { name: '<?= csrf_token() ?>', hash: '<?= csrf_hash() ?>' };
|
|
const URL_PREVIEW_QUERY = '<?= base_url('chart/preview-query') ?>';
|
|
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;
|
|
const d = dec ?? 1;
|
|
switch (fmt) {
|
|
case 'inr': return '₹' + n.toLocaleString(undefined, { maximumFractionDigits: d, minimumFractionDigits: d });
|
|
case 'usd': return '$' + n.toLocaleString(undefined, { maximumFractionDigits: d, minimumFractionDigits: d });
|
|
case 'percent': return n.toFixed(d) + '%';
|
|
case 'compact':
|
|
if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(d) + 'B';
|
|
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(d) + 'M';
|
|
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(d) + 'K';
|
|
return n.toFixed(d);
|
|
default: return n.toLocaleString(undefined, { maximumFractionDigits: d });
|
|
}
|
|
}
|
|
|
|
function applyFormatHints(opts, hints) {
|
|
if (!opts || !hints) return opts;
|
|
const fmt = hints.number_format || 'auto';
|
|
const dec = Number(hints.decimal_places ?? 1);
|
|
const fmtFn = (v) => formatAxisNumber(v, fmt, dec);
|
|
|
|
const patchY = (yax) => {
|
|
if (!yax || !yax.labels) return;
|
|
yax.labels.formatter = fmtFn;
|
|
};
|
|
|
|
if (opts.yaxis) {
|
|
if (Array.isArray(opts.yaxis)) opts.yaxis.forEach(patchY);
|
|
else patchY(opts.yaxis);
|
|
}
|
|
|
|
if (opts.plotOptions && opts.plotOptions.radialBar && opts.plotOptions.radialBar.dataLabels && opts.plotOptions.radialBar.dataLabels.value) {
|
|
opts.plotOptions.radialBar.dataLabels.value.formatter = fmtFn;
|
|
}
|
|
|
|
return opts;
|
|
}
|
|
|
|
function hslToHex(h, s, l) {
|
|
const hh = ((Number(h) % 360) + 360) % 360;
|
|
const ss = Math.max(0, Math.min(100, Number(s))) / 100;
|
|
const ll = Math.max(0, Math.min(100, Number(l))) / 100;
|
|
const c = (1 - Math.abs((2 * ll) - 1)) * ss;
|
|
const hp = hh / 60;
|
|
const x = c * (1 - Math.abs((hp % 2) - 1));
|
|
let r1 = 0, g1 = 0, b1 = 0;
|
|
if (hp >= 0 && hp < 1) { r1 = c; g1 = x; }
|
|
else if (hp < 2) { r1 = x; g1 = c; }
|
|
else if (hp < 3) { g1 = c; b1 = x; }
|
|
else if (hp < 4) { g1 = x; b1 = c; }
|
|
else if (hp < 5) { r1 = x; b1 = c; }
|
|
else { r1 = c; b1 = x; }
|
|
const m = ll - (c / 2);
|
|
const r = Math.round((r1 + m) * 255);
|
|
const g = Math.round((g1 + m) * 255);
|
|
const b = Math.round((b1 + m) * 255);
|
|
return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function ensureApexColorCoverage(opts) {
|
|
if (!opts || typeof opts !== 'object') return opts;
|
|
const seriesCount = Array.isArray(opts.series) ? opts.series.length : 0;
|
|
const labelCount = Array.isArray(opts.labels) ? opts.labels.length : 0;
|
|
const needed = Math.max(seriesCount, labelCount, 1);
|
|
const base = Array.isArray(opts.colors) ? opts.colors.filter((c) => /^#[0-9A-Fa-f]{6}$/.test(String(c))) : [];
|
|
const colors = base.length ? base.slice() : ['#36a2eb', '#4bc0c0', '#ffcd56', '#ff9f40', '#9966ff'];
|
|
for (let i = colors.length; i < needed; i++) {
|
|
const hue = (220 + (i * 137.508)) % 360;
|
|
const sat = 64 + ((i % 3) * 6);
|
|
const light = 50 - ((i % 4) * 4);
|
|
colors.push(hslToHex(hue, Math.min(78, sat), Math.max(36, light)));
|
|
}
|
|
opts.colors = colors.slice(0, needed);
|
|
return opts;
|
|
}
|
|
|
|
function patchCsrfFromResponse(j) {
|
|
if (!j || !j.csrf) return;
|
|
CB_CSRF.name = j.csrf.name;
|
|
CB_CSRF.hash = j.csrf.hash;
|
|
const form = document.getElementById('chart-builder-form');
|
|
const inp = form ? form.querySelector('input[name="' + j.csrf.name + '"]') : null;
|
|
if (inp) inp.value = j.csrf.hash;
|
|
}
|
|
|
|
function mountPayload(el, payload, hints) {
|
|
if (!el) return;
|
|
el.innerHTML = '';
|
|
if (!payload || payload.engine === 'empty') {
|
|
el.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 text-muted small p-4">' + (payload?.message || 'No data') + '</div>';
|
|
return;
|
|
}
|
|
if (payload.engine === 'table') {
|
|
const cols = payload.columns || [];
|
|
const rows = payload.rows || [];
|
|
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 = (focusState) => {
|
|
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) => {
|
|
const selStart = Number.isInteger(ev.target.selectionStart) ? ev.target.selectionStart : null;
|
|
const selEnd = Number.isInteger(ev.target.selectionEnd) ? ev.target.selectionEnd : selStart;
|
|
state.q = ev.target.value || '';
|
|
state.page = 1;
|
|
render({ focusSearch: true, selStart, selEnd });
|
|
});
|
|
}
|
|
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));
|
|
if (focusState && focusState.focusSearch) {
|
|
const nextSearch = el.querySelector('[data-cb-table-search]');
|
|
if (nextSearch) {
|
|
nextSearch.focus();
|
|
if (focusState.selStart != null && focusState.selEnd != null && typeof nextSearch.setSelectionRange === 'function') {
|
|
const len = String(nextSearch.value || '').length;
|
|
const s = Math.max(0, Math.min(len, focusState.selStart));
|
|
const e = Math.max(s, Math.min(len, focusState.selEnd));
|
|
nextSearch.setSelectionRange(s, e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
render();
|
|
return;
|
|
}
|
|
if (payload.engine === 'kpi') {
|
|
const cards = payload.cards || [];
|
|
let html = '<div class="cb-cb-kpi-grid">';
|
|
cards.forEach((k) => {
|
|
html += '<div class="cb-cb-kpi"><div class="cb-cb-kpi-val">' + (k.value || '') + '</div><div class="cb-cb-kpi-lbl">' + (k.label || '') + '</div></div>';
|
|
});
|
|
html += '</div>';
|
|
el.innerHTML = html || '<p class="text-muted small mb-0">No KPI columns.</p>';
|
|
return;
|
|
}
|
|
if (payload.engine === 'ranked_progress') {
|
|
const items = payload.items || [];
|
|
const footer = payload.footer || [];
|
|
let html = '<div class="cb-ranked-progress cb-ranked-progress--builder">';
|
|
if (payload.title) {
|
|
html += '<div class="cb-ranked-progress-title">' + escapeHtml(payload.title) + '</div>';
|
|
}
|
|
if (payload.subtitle) {
|
|
html += '<div class="cb-ranked-progress-subtitle text-muted small">' + escapeHtml(payload.subtitle) + '</div>';
|
|
}
|
|
items.forEach((it) => {
|
|
const pct = Math.max(0, Math.min(100, Number(it.bar_pct) || 0));
|
|
const col = String(it.color || '#36a2eb');
|
|
html += '<div class="cb-ranked-row">';
|
|
html += '<span class="cb-ranked-dot" style="background-color:' + escapeHtml(col) + '"></span>';
|
|
html += '<span class="cb-ranked-label">' + escapeHtml(it.label) + '</span>';
|
|
html += '<div class="cb-ranked-bar-wrap"><div class="cb-ranked-bar-track">';
|
|
html += '<div class="cb-ranked-bar-fill" style="width:' + pct + '%;background-color:' + escapeHtml(col) + '"></div>';
|
|
html += '</div></div>';
|
|
html += '<span class="cb-ranked-val">' + escapeHtml(it.value_label != null ? String(it.value_label) : String(it.value)) + '</span>';
|
|
html += '</div>';
|
|
});
|
|
if (footer.length) {
|
|
html += '<div class="cb-ranked-footer">';
|
|
footer.forEach((f) => {
|
|
const c = String(f.color || '#64748b');
|
|
html += '<span class="cb-ranked-pill" style="border-color:' + escapeHtml(c) + '40;color:' + escapeHtml(c) + '">';
|
|
html += '<span class="cb-ranked-pill-dot" style="background:' + escapeHtml(c) + '"></span>';
|
|
html += escapeHtml(f.label) + ' ' + (f.pct != null ? f.pct : 0) + '%</span>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
el.innerHTML = html;
|
|
return;
|
|
}
|
|
if (payload.engine === 'apex' && payload.options && typeof ApexCharts !== 'undefined') {
|
|
const opts = JSON.parse(JSON.stringify(payload.options));
|
|
applyFormatHints(opts, hints || payload.format_hints);
|
|
ensureApexColorCoverage(opts);
|
|
const chart = new ApexCharts(el, opts);
|
|
el._apexChart = chart;
|
|
const done = chart.render();
|
|
if (done && typeof done.catch === 'function') {
|
|
done.catch((err) => {
|
|
console.error(err);
|
|
el.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 text-danger small p-4">Chart failed to render. Try another type or refresh.</div>';
|
|
el._apexChart = null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
window.chartBuilder = function (boot) {
|
|
const defaultDisplay = () => ({
|
|
title: '',
|
|
subtitle: '',
|
|
show_legend: true,
|
|
show_data_labels: false,
|
|
show_grid: true,
|
|
smooth: true,
|
|
stepline: false,
|
|
horizontal_bar: false,
|
|
stacked: false,
|
|
color_mode: 'preset',
|
|
palette: 'ocean',
|
|
palette_colors: ['#36a2eb', '#4bc0c0', '#ffcd56', '#ff9f40', '#9966ff'],
|
|
number_format: 'auto',
|
|
decimal_places: 1,
|
|
y_min: '',
|
|
y_max: '',
|
|
secondary_y_field: '',
|
|
});
|
|
|
|
return {
|
|
boot,
|
|
savedQueryId: boot.savedQueryId || '',
|
|
chartType: boot.chartType || 'line',
|
|
chartName: boot.name || '',
|
|
chartDescription: boot.description || '',
|
|
xField: boot.xField || '',
|
|
yField: boot.yField || '',
|
|
groupField: boot.groupField || '',
|
|
valueField: boot.valueField || '',
|
|
refreshInterval: boot.refreshInterval ?? 0,
|
|
display: Object.assign(defaultDisplay(), boot.display || {}),
|
|
variableDefs: [],
|
|
variables: {},
|
|
columns: [],
|
|
previewRows: [],
|
|
previewMeta: '',
|
|
previewLoading: false,
|
|
previewError: '',
|
|
renderLoading: false,
|
|
renderError: '',
|
|
renderMeta: '',
|
|
autoRunPreview: true,
|
|
configTab: 'data',
|
|
recommendedTypes: [],
|
|
_renderTimer: null,
|
|
chartTypes: [
|
|
{ id: 'line', label: 'Line', icon: '📈' },
|
|
{ id: 'spline', label: 'Spline', icon: '〰️' },
|
|
{ id: 'stepline', label: 'Step line', icon: '📶' },
|
|
{ id: 'area', label: 'Area', icon: '📉' },
|
|
{ id: 'bar', label: 'Bar', icon: '📊' },
|
|
{ id: 'ranked_progress', label: 'Ranked list', icon: '📋' },
|
|
{ id: 'combo', label: 'Combo', icon: '⚡' },
|
|
{ id: 'pie', label: 'Pie', icon: '🥧' },
|
|
{ id: 'donut', label: 'Donut', icon: '🍩' },
|
|
{ id: 'polar_area', label: 'Polar area', icon: '⭕' },
|
|
{ id: 'scatter', label: 'Scatter', icon: '✦' },
|
|
{ id: 'bubble', label: 'Bubble', icon: '◎' },
|
|
{ id: 'radar', label: 'Radar', icon: '🕸️' },
|
|
{ id: 'funnel', label: 'Funnel', icon: '▽' },
|
|
{ id: 'heatmap', label: 'Heatmap', icon: '🌡️' },
|
|
{ id: 'gauge', label: 'Gauge', icon: '⏱️' },
|
|
{ id: 'kpi_card', label: 'KPI', icon: '🎯' },
|
|
{ id: 'table', label: 'Table', icon: '🗂️' },
|
|
],
|
|
|
|
init() {
|
|
this.ensurePaletteColors();
|
|
if (this.savedQueryId) {
|
|
this.loadVariables();
|
|
}
|
|
if (this.boot.mode === 'edit' && this.savedQueryId) {
|
|
this.runPreview();
|
|
}
|
|
},
|
|
|
|
labelForType(typeId) {
|
|
const t = this.chartTypes.find((x) => x.id === typeId);
|
|
return t ? t.label : typeId;
|
|
},
|
|
|
|
selectChartType(typeId) {
|
|
this.chartType = typeId;
|
|
this.autoMapFields(false);
|
|
this.queueRenderPreview();
|
|
},
|
|
|
|
ensurePaletteColors() {
|
|
const defs = ['#36a2eb', '#4bc0c0', '#ffcd56', '#ff9f40', '#9966ff', '#2f7ed8', '#26c6da', '#f87171'];
|
|
if (!Array.isArray(this.display.palette_colors)) {
|
|
this.display.palette_colors = defs.slice(0);
|
|
} else {
|
|
for (let i = 0; i < 12; i++) {
|
|
if (this.display.palette_colors[i] == null || this.display.palette_colors[i] === '') {
|
|
this.display.palette_colors[i] = defs[i % defs.length] || '#111827';
|
|
}
|
|
}
|
|
}
|
|
if (this.display.color_mode !== 'custom' && this.display.color_mode !== 'preset') {
|
|
this.display.color_mode = 'preset';
|
|
}
|
|
},
|
|
|
|
async loadVariables() {
|
|
if (!this.savedQueryId) return;
|
|
try {
|
|
const r = await fetch(URL_VARS(this.savedQueryId), { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
|
const j = await r.json();
|
|
if (!j.success) return;
|
|
this.variableDefs = j.variables || [];
|
|
const next = {};
|
|
this.variableDefs.forEach((v) => { next[String(v.name).toLowerCase()] = v.default_value || ''; });
|
|
this.variables = Object.assign(next, this.variables);
|
|
} catch (e) { /* ignore */ }
|
|
},
|
|
|
|
onQueryChange() {
|
|
this.columns = [];
|
|
this.previewRows = [];
|
|
this.previewMeta = '';
|
|
this.previewError = '';
|
|
this.recommendedTypes = [];
|
|
this.loadVariables();
|
|
this.queueRenderPreview();
|
|
},
|
|
|
|
/**
|
|
* Map saved field names onto <select> options after columns load (Alpine + x-for
|
|
* does not always re-sync x-model when options appear later).
|
|
*/
|
|
applySavedColumnSelections() {
|
|
const cols = this.columns;
|
|
if (!cols || cols.length === 0) {
|
|
return;
|
|
}
|
|
const resolve = (val) => {
|
|
if (val == null || val === '') {
|
|
return '';
|
|
}
|
|
const s = String(val);
|
|
if (cols.includes(s)) {
|
|
return s;
|
|
}
|
|
const found = cols.find((c) => String(c).toLowerCase() === s.toLowerCase());
|
|
return found || s;
|
|
};
|
|
const b = this.boot;
|
|
this.xField = resolve(b.xField);
|
|
this.yField = resolve(b.yField);
|
|
this.groupField = resolve(b.groupField);
|
|
this.valueField = resolve(b.valueField);
|
|
const sec = (b.display && b.display.secondary_y_field) ? b.display.secondary_y_field : this.display.secondary_y_field;
|
|
this.display.secondary_y_field = resolve(sec);
|
|
},
|
|
|
|
async runPreview() {
|
|
this.previewLoading = true;
|
|
this.previewError = '';
|
|
const body = new URLSearchParams();
|
|
body.append(CB_CSRF.name, CB_CSRF.hash);
|
|
body.append('saved_query_id', String(this.savedQueryId));
|
|
body.append('variables_json', JSON.stringify(this.variables));
|
|
try {
|
|
const r = await fetch(URL_PREVIEW_QUERY, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
const j = await r.json();
|
|
if (!j.success) {
|
|
this.previewError = j.message || 'Preview failed';
|
|
this.columns = [];
|
|
this.previewRows = [];
|
|
return;
|
|
}
|
|
const d = j.data;
|
|
this.columns = d.columns || [];
|
|
this.previewRows = d.rows || [];
|
|
this.previewMeta = (d.row_count ?? 0) + ' rows · ' + (d.execution_ms ?? 0) + ' ms' + (d.cache_hit ? ' · cache' : '');
|
|
patchCsrfFromResponse(j);
|
|
this.recommendedTypes = this.getRecommendedTypes();
|
|
// Two ticks: first updates columns; second runs after x-if mounts field <select>s
|
|
this.$nextTick(() => {
|
|
this.applySavedColumnSelections();
|
|
this.autoMapFields(false);
|
|
this.queueRenderPreview();
|
|
this.$nextTick(() => this.applySavedColumnSelections());
|
|
});
|
|
} catch (e) {
|
|
this.previewError = e.message || 'Network error';
|
|
} finally {
|
|
this.previewLoading = false;
|
|
}
|
|
},
|
|
|
|
inferColumnKind(colName) {
|
|
const key = String(colName || '').toLowerCase();
|
|
if (key.includes('date') || key.includes('time') || key.endsWith('_at')) return 'date';
|
|
let numeric = 0;
|
|
let dated = 0;
|
|
let total = 0;
|
|
for (const r of this.previewRows.slice(0, 30)) {
|
|
if (!r || !(colName in r)) continue;
|
|
const v = r[colName];
|
|
if (v === null || v === '') continue;
|
|
total += 1;
|
|
if (!Number.isNaN(Number(v))) numeric += 1;
|
|
if (!Number.isNaN(Date.parse(String(v)))) dated += 1;
|
|
}
|
|
if (total > 0 && dated / total >= 0.7) return 'date';
|
|
if (total > 0 && numeric / total >= 0.7) return 'number';
|
|
return 'category';
|
|
},
|
|
|
|
getRecommendedTypes() {
|
|
if (!this.columns.length) return [];
|
|
const nums = this.columns.filter((c) => this.inferColumnKind(c) === 'number');
|
|
const dates = this.columns.filter((c) => this.inferColumnKind(c) === 'date');
|
|
const cats = this.columns.filter((c) => this.inferColumnKind(c) === 'category');
|
|
const out = [];
|
|
if (dates.length && nums.length) out.push('line', 'area');
|
|
if (cats.length && nums.length) out.push('bar', 'ranked_progress');
|
|
if (cats.length && nums.length === 1) out.push('donut', 'pie');
|
|
if (nums.length >= 2) out.push('scatter');
|
|
out.push('table', 'kpi_card');
|
|
return Array.from(new Set(out)).slice(0, 6);
|
|
},
|
|
|
|
autoMapFields(force) {
|
|
if (!this.columns.length) return;
|
|
const nums = this.columns.filter((c) => this.inferColumnKind(c) === 'number');
|
|
const dates = this.columns.filter((c) => this.inferColumnKind(c) === 'date');
|
|
const cats = this.columns.filter((c) => this.inferColumnKind(c) === 'category');
|
|
const defaultX = dates[0] || cats[0] || this.columns[0] || '';
|
|
const defaultY = nums[0] || this.columns[0] || '';
|
|
const defaultGroup = cats.find((c) => c !== defaultX) || '';
|
|
const setIf = (curr, val) => (force || !curr || !this.columns.includes(curr)) ? val : curr;
|
|
|
|
if (this.needsXY()) {
|
|
this.xField = setIf(this.xField, defaultX);
|
|
this.yField = setIf(this.yField, defaultY);
|
|
}
|
|
if (this.showGroup()) {
|
|
this.groupField = setIf(this.groupField, defaultGroup);
|
|
}
|
|
if (this.chartType === 'bubble') {
|
|
this.valueField = setIf(this.valueField, nums[1] || nums[0] || '');
|
|
}
|
|
if (this.chartType === 'gauge' || this.chartType === 'kpi_card') {
|
|
this.valueField = setIf(this.valueField, defaultY);
|
|
}
|
|
if (this.chartType === 'combo') {
|
|
const alt = nums.find((n) => n !== (this.yField || defaultY)) || '';
|
|
this.display.secondary_y_field = setIf(this.display.secondary_y_field, alt || this.yField || defaultY);
|
|
}
|
|
if (this.chartType === 'table') {
|
|
this.xField = '';
|
|
this.yField = '';
|
|
this.groupField = '';
|
|
this.valueField = '';
|
|
}
|
|
},
|
|
|
|
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 && 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);
|
|
if (this.needsXY()) return !!(this.xField && this.yField);
|
|
return true;
|
|
},
|
|
|
|
queueRenderPreview() {
|
|
clearTimeout(this._renderTimer);
|
|
if (!this.autoRunPreview) return;
|
|
this._renderTimer = setTimeout(() => {
|
|
if (this.canRenderPreview()) this.renderLivePreview();
|
|
}, 320);
|
|
},
|
|
|
|
needsXY() {
|
|
return [
|
|
'bar', 'line', 'area', 'spline', 'stepline',
|
|
'pie', 'donut', 'polar_area', 'scatter', 'bubble', 'radar',
|
|
'funnel', 'heatmap', 'combo', 'ranked_progress',
|
|
].includes(this.chartType);
|
|
},
|
|
|
|
showGroup() {
|
|
return ['bar', 'line', 'area', 'spline', 'stepline', 'heatmap', 'radar'].includes(this.chartType);
|
|
},
|
|
|
|
typeHint() {
|
|
const t = this.chartType;
|
|
const map = {
|
|
line: 'Line: X = category or date, Y = numeric. Optional group splits series.',
|
|
spline: 'Spline: smooth line chart; same field mapping as line.',
|
|
stepline: 'Step line: values change in steps; same mapping as line.',
|
|
area: 'Area: same as line; great for volume over time.',
|
|
bar: 'Bar: X = category, Y = numeric. Toggle horizontal in Style.',
|
|
ranked_progress: 'Ranked list: X = category label, Y = numeric value. Rows aggregate by label; bars scale to the largest value; footer shows top shares of total.',
|
|
combo: 'Combo: columns from Y, line from second metric. Pick Line series in Fields.',
|
|
pie: 'Pie: X = slice labels, Y = numeric values.',
|
|
donut: 'Donut: same as pie with a center cutout.',
|
|
polar_area: 'Polar area: same mapping as pie (labels + values), shown on a polar axis.',
|
|
scatter: 'Scatter: X and Y should be numeric columns.',
|
|
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. 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.',
|
|
};
|
|
return map[t] || '';
|
|
},
|
|
|
|
goStep(n) {
|
|
if (n < 1 || n > 5) return;
|
|
this.step = n;
|
|
if (n === 3 && this.columns.length) {
|
|
this.$nextTick(() => {
|
|
this.applySavedColumnSelections();
|
|
this.$nextTick(() => this.applySavedColumnSelections());
|
|
});
|
|
}
|
|
if (n === 5) this.$nextTick(() => this.renderLivePreview());
|
|
},
|
|
|
|
nextStep() {
|
|
if (this.step === 1) {
|
|
if (!this.savedQueryId) { alert('Select a query.'); return; }
|
|
if (!this.columns.length) { alert('Run preview first to load columns.'); return; }
|
|
}
|
|
if (this.step === 3) {
|
|
if (this.chartType === 'table') { this.goStep(4); return; }
|
|
if (this.chartType === 'gauge' || this.chartType === 'kpi_card') {
|
|
if (!this.valueField && !this.yField) { alert('Select a value column.'); return; }
|
|
} else if (this.needsXY()) {
|
|
if (!this.xField || !this.yField) { alert('Select X and Y columns.'); return; }
|
|
if (this.chartType === 'bubble' && !this.valueField) {
|
|
alert('Bubble charts need a size (Z) column.');
|
|
return;
|
|
}
|
|
}
|
|
if (this.chartType === 'combo' && !this.display.secondary_y_field) {
|
|
alert('Combo needs a second metric for the line.'); return;
|
|
}
|
|
}
|
|
this.goStep(this.step + 1);
|
|
},
|
|
|
|
displayJson() {
|
|
const d = { ...this.display };
|
|
d.decimal_places = Number(d.decimal_places);
|
|
d.show_legend = !!d.show_legend;
|
|
d.show_data_labels = !!d.show_data_labels;
|
|
d.show_grid = !!d.show_grid;
|
|
d.smooth = !!d.smooth;
|
|
d.stepline = !!d.stepline;
|
|
d.horizontal_bar = !!d.horizontal_bar;
|
|
d.stacked = !!d.stacked;
|
|
d.color_mode = d.color_mode === 'custom' ? 'custom' : 'preset';
|
|
if (Array.isArray(d.palette_colors)) {
|
|
d.palette_colors = d.palette_colors.map((c) => String(c || '').trim()).filter(Boolean);
|
|
}
|
|
if (this.chartType === 'kpi_card') {
|
|
const col = this.valueField || this.yField;
|
|
if (col) d.kpi_columns = [col];
|
|
}
|
|
return JSON.stringify(d);
|
|
},
|
|
|
|
beforeSubmit(ev) {
|
|
if (!this.savedQueryId) {
|
|
ev.preventDefault();
|
|
alert('Select a query.');
|
|
}
|
|
},
|
|
|
|
async renderLivePreview() {
|
|
const el = document.getElementById('cb-apex-preview');
|
|
if (el && el._apexChart) {
|
|
try { el._apexChart.destroy(); } catch (e) { /* */ }
|
|
el._apexChart = null;
|
|
}
|
|
if (this.chartType === 'heatmap' && !this.groupField) {
|
|
this.renderError = 'Heatmap needs Group / series field (row dimension).';
|
|
this.renderMeta = '';
|
|
if (el) {
|
|
el.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 text-muted small p-4">Select Group / series to render heatmap.</div>';
|
|
}
|
|
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 || '';
|
|
}
|
|
this.renderLoading = true;
|
|
this.renderError = '';
|
|
const body = new URLSearchParams();
|
|
body.append(CB_CSRF.name, CB_CSRF.hash);
|
|
body.append('saved_query_id', String(this.savedQueryId));
|
|
body.append('variables_json', JSON.stringify(this.variables));
|
|
body.append('chart_type', this.chartType);
|
|
body.append('x_field', this.xField);
|
|
body.append('y_field', this.yField);
|
|
body.append('group_field', this.chartType === 'combo' ? '' : this.groupField);
|
|
body.append('value_field', this.valueField);
|
|
body.append('display_config', this.displayJson());
|
|
try {
|
|
const r = await fetch(URL_PREVIEW_RENDER, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
const j = await r.json();
|
|
if (!j.success) {
|
|
this.renderError = j.message || 'Render failed';
|
|
this.renderMeta = '';
|
|
return;
|
|
}
|
|
mountPayload(el, j.payload, j.payload?.format_hints);
|
|
this.renderMeta = (j.meta?.row_count ?? 0) + ' rows · ' + (j.meta?.execution_ms ?? 0) + ' ms' + (j.meta?.cache_hit ? ' · cache' : '');
|
|
patchCsrfFromResponse(j);
|
|
} catch (e) {
|
|
this.renderError = e.message || 'Network error';
|
|
this.renderMeta = '';
|
|
} finally {
|
|
this.renderLoading = false;
|
|
}
|
|
},
|
|
};
|
|
};
|
|
})();
|
|
</script>
|
|
<style>[x-cloak]{display:none!important}</style>
|