chartboard/app/Views/chart/builder_scripts.php
2026-04-10 12:10:43 +05:30

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) => ({ '&': '&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;
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>