GWM : chart improvemnet

This commit is contained in:
Gowtham M 2026-03-31 15:39:53 +05:30
parent ade83e50bb
commit f9f935e78e
3 changed files with 414 additions and 274 deletions

View File

@ -27,7 +27,7 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
<header class="cb-page-header cb-page-header--center">
<div>
<h1 class="cb-page-title"><?= $mode === 'edit' ? 'Edit chart' : 'Create chart' ?></h1>
<p class="cb-page-subtitle mb-0">Choose a saved query, map fields, and preview with ApexCharts.</p>
<p class="cb-page-subtitle mb-0">Visualization-first composer: connect query, pick recommended chart, and tune with live preview.</p>
</div>
<div class="d-flex flex-wrap gap-2 justify-content-end">
<?php if ($mode === 'edit' && ! empty($cRow['id'])): ?>
@ -47,18 +47,6 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<div class="cb-cb-stepbar">
<template x-for="(s, idx) in steps" :key="s.n">
<span class="d-inline-flex align-items-center">
<button type="button" class="cb-cb-pill" :class="{ active: step === s.n, done: step > s.n }" @click="goStep(s.n)">
<span class="cb-cb-pill-num" x-text="step > s.n ? '✓' : s.n"></span>
<span x-text="s.label"></span>
</button>
<span class="cb-cb-connector" x-show="idx < steps.length - 1"></span>
</span>
</template>
</div>
<form id="chart-builder-form" method="post"
:action="boot.mode === 'edit' ? '<?= base_url('chart/update/') ?>' + boot.chartId : '<?= base_url('chart/store') ?>'"
@submit="beforeSubmit($event)">
@ -66,296 +54,306 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
<input type="hidden" name="display_config" :value="displayJson()">
<input type="hidden" name="chart_type" :value="chartType">
<!-- POST uses these so values match Alpine state (visible selects omit name to avoid empty submits) -->
<input type="hidden" name="saved_query_id" :value="savedQueryId">
<input type="hidden" name="x_field" :value="xField">
<input type="hidden" name="y_field" :value="yField">
<input type="hidden" name="group_field" :value="groupField">
<input type="hidden" name="value_field" :value="valueField">
<!-- Step 1: Query -->
<div x-show="step === 1" x-cloak>
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-h">
<span class="fw-bold">Saved query</span>
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-h">
<span class="fw-bold">Query selector</span>
<div class="d-flex gap-2 align-items-center">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="cbAutoRun" x-model="autoRunPreview">
<label class="form-check-label small" for="cbAutoRun">Auto-run preview</label>
</div>
<button type="button" class="btn btn-dark btn-sm" @click.prevent="runPreview()" :disabled="!savedQueryId || previewLoading">
<span x-show="!previewLoading">Run preview</span>
<span x-show="!previewLoading">Run</span>
<span x-show="previewLoading" class="spinner-border spinner-border-sm"></span>
</button>
</div>
<div class="cb-cb-card-b">
<label class="form-label small fw-semibold">Query</label>
<select class="form-select" x-model.number="savedQueryId" @change="onQueryChange()" required>
<option value=""> Select a query </option>
<?php foreach ($queries as $q): ?>
<option value="<?= (int) $q['id'] ?>"><?= esc($q['name']) ?> (<?= esc($q['data_source_name'] ?? '') ?>)</option>
<?php endforeach; ?>
</select>
<p class="small text-muted mt-2 mb-0">The data source is taken from the query; you do not pick it separately.</p>
<template x-if="variableDefs.length">
<div class="mt-3 p-3 rounded-3 bg-light border">
</div>
<div class="cb-cb-card-b">
<div class="row g-3">
<div class="col-lg-6">
<label class="form-label small fw-semibold">Saved query</label>
<select class="form-select" x-model.number="savedQueryId" @change="onQueryChange()" required>
<option value=""> Select a query </option>
<?php foreach ($queries as $q): ?>
<option value="<?= (int) $q['id'] ?>"><?= esc($q['name']) ?> (<?= esc($q['data_source_name'] ?? '') ?>)</option>
<?php endforeach; ?>
</select>
<p class="small text-muted mt-2 mb-0">Data source comes from the query automatically.</p>
</div>
<div class="col-lg-6" x-show="variableDefs.length" x-cloak>
<div class="p-3 rounded-3 bg-light border h-100">
<div class="small fw-bold mb-2">Query variables</div>
<template x-for="v in variableDefs" :key="v.name">
<div class="mb-2">
<label class="form-label small mb-0" x-text="v.label || v.name"></label>
<input type="text" class="form-control form-control-sm" :placeholder="v.default_value || ''"
x-model="variables[v.name]">
<input type="text" class="form-control form-control-sm" :placeholder="v.default_value || ''" x-model="variables[v.name]" @input="queueRenderPreview()">
</div>
</template>
</div>
</template>
<p class="text-danger small mt-2 mb-0" x-show="previewError" x-text="previewError"></p>
</div>
</div>
<div class="cb-cb-card" x-show="columns.length">
<div class="cb-cb-card-h">
<span class="fw-bold">Result preview</span>
<span class="small text-muted" x-text="previewMeta"></span>
</div>
<div class="cb-cb-card-b p-0 table-responsive">
<table class="table table-sm table-hover mb-0 small">
<thead class="table-light"><tr>
<template x-for="c in columns" :key="c"><th x-text="c"></th></template>
</tr></thead>
<tbody>
<template x-for="(row, ri) in previewRows" :key="ri">
<tr>
<template x-for="c in columns" :key="c"><td x-text="row[c] ?? ''"></td></template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<p class="text-danger small mt-2 mb-0" x-show="previewError" x-text="previewError"></p>
</div>
</div>
<!-- Step 2: Type -->
<div x-show="step === 2" x-cloak>
<div class="cb-cb-card">
<div class="cb-cb-card-h">
<span class="fw-bold">Chart type</span>
<div class="row g-3 align-items-start">
<div class="col-lg-5">
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-h"><span class="fw-bold">Recommended charts</span></div>
<div class="cb-cb-card-b">
<div class="d-flex flex-wrap gap-2" x-show="recommendedTypes.length" x-cloak>
<template x-for="rid in recommendedTypes" :key="rid">
<button type="button" class="cb-cb-type cb-cb-type--compact" :class="{ selected: chartType === rid }" @click="selectChartType(rid)">
<span x-text="labelForType(rid)"></span>
</button>
</template>
</div>
<p class="small text-muted mb-0" x-show="!recommendedTypes.length">Run query once to get chart recommendations based on your columns.</p>
<details class="mt-3">
<summary class="small fw-semibold">All chart types</summary>
<div class="cb-cb-type-grid mt-2">
<template x-for="t in chartTypes" :key="t.id">
<button type="button" class="cb-cb-type" :class="{ selected: chartType === t.id }" @click="selectChartType(t.id)">
<span class="cb-cb-type-ico" x-text="t.icon"></span>
<span x-text="t.label"></span>
</button>
</template>
</div>
</details>
<div class="cb-cb-hint mt-3"><span x-text="typeHint()"></span></div>
</div>
</div>
<div class="cb-cb-card-b">
<div class="cb-cb-type-grid">
<template x-for="t in chartTypes" :key="t.id">
<button type="button" class="cb-cb-type" :class="{ selected: chartType === t.id }" @click="chartType = t.id">
<span class="cb-cb-type-ico" x-text="t.icon"></span>
<span x-text="t.label"></span>
</button>
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-h"><span class="fw-bold">Field mapping</span></div>
<div class="cb-cb-card-b">
<template x-if="!columns.length">
<p class="text-muted small mb-0">Run query first to load columns and enable mapping.</p>
</template>
<template x-if="columns.length">
<div>
<div class="cb-cb-fm-row" x-show="needsXY()">
<div><span class="fw-semibold small">X axis</span><div class="text-muted small">Category / date</div></div>
<select class="form-select form-select-sm" x-model="xField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="needsXY()">
<div><span class="fw-semibold small">Y axis</span><div class="text-muted small">Numeric values</div></div>
<select class="form-select form-select-sm" x-model="yField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="showGroup()">
<div><span class="fw-semibold small">Group / series</span><div class="text-muted small">Optional split</div></div>
<select class="form-select form-select-sm" x-model="groupField" @change="queueRenderPreview()">
<option value=""> None </option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'bubble'">
<div><span class="fw-semibold small">Bubble size (Z)</span></div>
<select class="form-select form-select-sm" x-model="valueField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'gauge' || chartType === 'kpi_card'">
<div><span class="fw-semibold small">Value column</span></div>
<select class="form-select form-select-sm" x-model="valueField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'combo'">
<div><span class="fw-semibold small">Line series (2nd metric)</span></div>
<select class="form-select form-select-sm" x-model="display.secondary_y_field" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
</div>
</template>
</div>
<div class="cb-cb-hint mt-3"><span x-text="typeHint()"></span></div>
</div>
</div>
</div>
<!-- Step 3: Fields selects mount only after columns load so x-model binds with options present (valid HTML + saved values show) -->
<div x-show="step === 3" x-cloak>
<div class="cb-cb-card">
<div class="cb-cb-card-h"><span class="fw-bold">Field mapping</span></div>
<div class="cb-cb-card-b">
<template x-if="!columns.length">
<p class="text-muted small mb-0">Run preview on step 1 first column list is required for field mapping.</p>
</template>
<template x-if="columns.length">
<div>
<div class="cb-cb-fm-row" x-show="needsXY()">
<div><span class="fw-semibold small">X axis</span><div class="text-muted small">Categories or time</div></div>
<select class="form-select form-select-sm" x-model="xField">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="needsXY()">
<div><span class="fw-semibold small">Y axis</span><div class="text-muted small">Numeric values</div></div>
<select class="form-select form-select-sm" x-model="yField">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="showGroup()">
<div><span class="fw-semibold small">Group / series</span><div class="text-muted small">Optional</div></div>
<select class="form-select form-select-sm" x-model="groupField">
<option value=""> None </option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'bubble'">
<div><span class="fw-semibold small">Bubble size (Z)</span><div class="text-muted small">Numeric column</div></div>
<select class="form-select form-select-sm" x-model="valueField">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'gauge' || chartType === 'kpi_card'">
<div><span class="fw-semibold small">Value column</span></div>
<select class="form-select form-select-sm" x-model="valueField">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'combo'">
<div><span class="fw-semibold small">Line series (2nd metric)</span></div>
<select class="form-select form-select-sm" x-model="display.secondary_y_field">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<p class="small text-muted mb-0" x-show="chartType === 'table'">Table view shows all columns from the query result.</p>
<div class="cb-cb-card">
<div class="cb-cb-card-h"><span class="fw-bold">Basic style</span></div>
<div class="cb-cb-card-b">
<div class="mb-2">
<label class="form-label small">Chart title</label>
<input type="text" class="form-control form-control-sm" x-model="display.title" @input="queueRenderPreview()" placeholder="Optional">
</div>
</template>
</div>
</div>
</div>
<!-- Step 4: Style -->
<div x-show="step === 4" x-cloak>
<div class="row g-3">
<div class="col-lg-6">
<div class="cb-cb-card h-100">
<div class="cb-cb-card-h"><span class="fw-bold">Titles & format</span></div>
<div class="cb-cb-card-b">
<div class="mb-2">
<label class="form-label small">Chart title</label>
<input type="text" class="form-control form-control-sm" x-model="display.title" placeholder="Optional">
</div>
<div class="mb-2">
<label class="form-label small">Subtitle</label>
<input type="text" class="form-control form-control-sm" x-model="display.subtitle">
</div>
<div class="mb-2">
<label class="form-label small">Number format</label>
<select class="form-select form-select-sm" x-model="display.number_format">
<option value="auto">Auto</option>
<option value="inr"> INR</option>
<option value="usd">$ USD</option>
<option value="percent">%</option>
<option value="compact">K / M / B</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small">Decimal places</label>
<select class="form-select form-select-sm" x-model.number="display.decimal_places">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small">Number format</label>
<select class="form-select form-select-sm" x-model="display.number_format" @change="queueRenderPreview()">
<option value="auto">Auto</option>
<option value="inr"> INR</option>
<option value="usd">$ USD</option>
<option value="percent">%</option>
<option value="compact">K / M / B</option>
</select>
</div>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="lg" x-model="display.show_legend" @change="queueRenderPreview()">
<label class="form-check-label small" for="lg">Legend</label>
</div>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="dl" x-model="display.show_data_labels" @change="queueRenderPreview()">
<label class="form-check-label small" for="dl">Data labels</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="gr" x-model="display.show_grid" @change="queueRenderPreview()">
<label class="form-check-label small" for="gr">Grid lines</label>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="cb-cb-card h-100">
<div class="cb-cb-card-h"><span class="fw-bold">Look & axis</span></div>
<div class="cb-cb-card-b">
<div class="mb-2">
<label class="form-label small">Colors</label>
<select class="form-select form-select-sm" x-model="display.color_mode" @change="ensurePaletteColors()">
<option value="preset">Theme (preset)</option>
<option value="custom">Custom (pick swatches)</option>
</select>
</div>
<div class="mb-2" x-show="display.color_mode === 'preset'">
<label class="form-label small">Palette</label>
<select class="form-select form-select-sm" x-model="display.palette">
<option value="mono">Mono</option>
<option value="ocean">Ocean</option>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
<option value="slate">Slate</option>
<option value="violet">Violet</option>
</select>
</div>
<div class="mb-2" x-show="display.color_mode === 'custom'">
<label class="form-label small">Swatches (up to 5)</label>
<div class="d-flex flex-wrap gap-2 align-items-center">
<template x-for="idx in [0,1,2,3,4]" :key="idx">
<input type="color" class="form-control form-control-color cb-cb-color-swatch"
:title="'Color ' + (idx + 1)"
x-model="display.palette_colors[idx]">
</template>
</div>
</div>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="lg" x-model="display.show_legend">
<label class="form-check-label small" for="lg">Legend</label>
</div>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="dl" x-model="display.show_data_labels">
<label class="form-check-label small" for="dl">Data labels</label>
</div>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="gr" x-model="display.show_grid">
<label class="form-check-label small" for="gr">Grid lines</label>
</div>
<div class="form-check form-switch mb-1" x-show="chartType === 'line' || chartType === 'area'">
<input class="form-check-input" type="checkbox" id="sm" x-model="display.smooth">
<label class="form-check-label small" for="sm">Smooth curves</label>
</div>
<div class="form-check form-switch mb-1" x-show="chartType === 'line' || chartType === 'area'">
<input class="form-check-input" type="checkbox" id="stp" x-model="display.stepline">
<label class="form-check-label small" for="stp">Step line</label>
</div>
<div class="form-check form-switch mb-1" x-show="chartType === 'bar'">
<input class="form-check-input" type="checkbox" id="hb" x-model="display.horizontal_bar">
<label class="form-check-label small" for="hb">Horizontal bars</label>
</div>
<div class="form-check form-switch mb-1" x-show="chartType === 'bar' || chartType === 'area' || chartType === 'line' || chartType === 'spline' || chartType === 'stepline'">
<input class="form-check-input" type="checkbox" id="st" x-model="display.stacked">
<label class="form-check-label small" for="st">Stacked</label>
</div>
<div class="row g-2 mt-1" x-show="needsXY()">
<div class="col-6">
<label class="form-label small">Y min</label>
<input type="text" class="form-control form-control-sm" x-model="display.y_min" placeholder="Auto">
</div>
<div class="col-6">
<label class="form-label small">Y max</label>
<input type="text" class="form-control form-control-sm" x-model="display.y_max" placeholder="Auto">
</div>
</div>
</div>
<div class="col-lg-7">
<div class="cb-cb-card cb-cb-preview-card">
<div class="cb-cb-card-h">
<span class="fw-bold">Live preview</span>
<div class="d-flex gap-2 align-items-center">
<span class="small text-muted" x-text="renderMeta"></span>
<button type="button" class="btn btn-outline-secondary btn-sm" @click.prevent="renderLivePreview()" :disabled="renderLoading || !columns.length">Refresh</button>
</div>
</div>
<div class="cb-cb-card-b">
<?php if ($mode === 'edit' && ! empty($cRow['id'])): ?>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbExportPng">Export PNG</button>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=csv') ?>">Export CSV</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=excel') ?>">Export Excel</a>
</div>
<?php endif; ?>
<div id="cb-apex-preview" class="cb-cb-preview-host p-2"></div>
<p class="text-danger small mt-2 mb-0" x-show="renderError" x-text="renderError"></p>
</div>
</div>
</div>
</div>
<!-- Step 5: Preview & save -->
<div x-show="step === 5" x-cloak>
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-h">
<span class="fw-bold">Live preview</span>
<button type="button" class="btn btn-outline-secondary btn-sm" @click.prevent="renderLivePreview()" :disabled="renderLoading">
Refresh
</button>
</div>
<div class="cb-cb-card-b">
<?php if ($mode === 'edit' && ! empty($cRow['id'])): ?>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbExportPng">Export PNG</button>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=csv') ?>">Export CSV</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=excel') ?>">Export Excel</a>
<div class="cb-cb-card mt-3">
<div class="cb-cb-card-h"><span class="fw-bold">Advanced settings & save</span></div>
<div class="cb-cb-card-b">
<div class="accordion" id="cbAdvancedAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#cbAdvancedStyle">Advanced style</button>
</h2>
<div id="cbAdvancedStyle" class="accordion-collapse collapse" data-bs-parent="#cbAdvancedAccordion">
<div class="accordion-body">
<div class="row g-3">
<div class="col-lg-6">
<label class="form-label small">Subtitle</label>
<input type="text" class="form-control form-control-sm" x-model="display.subtitle" @input="queueRenderPreview()">
</div>
<div class="col-lg-3">
<label class="form-label small">Decimal places</label>
<select class="form-select form-select-sm" x-model.number="display.decimal_places" @change="queueRenderPreview()">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
<div class="col-lg-3">
<label class="form-label small">Colors</label>
<select class="form-select form-select-sm" x-model="display.color_mode" @change="ensurePaletteColors(); queueRenderPreview();">
<option value="preset">Theme</option>
<option value="custom">Custom</option>
</select>
</div>
</div>
<div class="row g-3 mt-1" x-show="display.color_mode === 'preset'">
<div class="col-lg-4">
<label class="form-label small">Palette</label>
<select class="form-select form-select-sm" x-model="display.palette" @change="queueRenderPreview()">
<option value="mono">Mono</option>
<option value="ocean">Ocean</option>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
<option value="slate">Slate</option>
<option value="violet">Violet</option>
</select>
</div>
</div>
<div class="mt-2" x-show="display.color_mode === 'custom'">
<label class="form-label small">Swatches</label>
<div class="d-flex flex-wrap gap-2 align-items-center">
<template x-for="idx in [0,1,2,3,4]" :key="idx">
<input type="color" class="form-control form-control-color cb-cb-color-swatch" :title="'Color ' + (idx + 1)" x-model="display.palette_colors[idx]" @input="queueRenderPreview()">
</template>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6" x-show="needsXY()">
<label class="form-label small">Y min</label>
<input type="text" class="form-control form-control-sm" x-model="display.y_min" @input="queueRenderPreview()" placeholder="Auto">
</div>
<div class="col-md-6" x-show="needsXY()">
<label class="form-label small">Y max</label>
<input type="text" class="form-control form-control-sm" x-model="display.y_max" @input="queueRenderPreview()" placeholder="Auto">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-12">
<div class="small fw-semibold text-muted mb-2">Chart behavior</div>
<div class="d-flex flex-wrap gap-3">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advLegend" x-model="display.show_legend" @change="queueRenderPreview()">
<label class="form-check-label small" for="advLegend">Legend</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advLabels" x-model="display.show_data_labels" @change="queueRenderPreview()">
<label class="form-check-label small" for="advLabels">Data labels</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advGrid" x-model="display.show_grid" @change="queueRenderPreview()">
<label class="form-check-label small" for="advGrid">Grid lines</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'line' || chartType === 'area'">
<input class="form-check-input" type="checkbox" id="advSmooth" x-model="display.smooth" @change="queueRenderPreview()">
<label class="form-check-label small" for="advSmooth">Smooth curves</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'line' || chartType === 'area'">
<input class="form-check-input" type="checkbox" id="advStepline" x-model="display.stepline" @change="queueRenderPreview()">
<label class="form-check-label small" for="advStepline">Step line</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'bar'">
<input class="form-check-input" type="checkbox" id="advHorizontalBar" x-model="display.horizontal_bar" @change="queueRenderPreview()">
<label class="form-check-label small" for="advHorizontalBar">Horizontal bars</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'bar' || chartType === 'area' || chartType === 'line' || chartType === 'spline' || chartType === 'stepline'">
<input class="form-check-input" type="checkbox" id="advStacked" x-model="display.stacked" @change="queueRenderPreview()">
<label class="form-check-label small" for="advStacked">Stacked</label>
</div>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<div id="cb-apex-preview" class="cb-cb-preview-host p-2"></div>
<p class="text-danger small mt-2 mb-0" x-show="renderError" x-text="renderError"></p>
</div>
</div>
</div>
<div class="cb-cb-card">
<div class="cb-cb-card-h"><span class="fw-bold">Save</span></div>
<div class="cb-cb-card-b">
<div class="mb-2">
<div class="row g-3 mt-1">
<div class="col-lg-6">
<label class="form-label small">Chart name</label>
<input type="text" class="form-control" name="name" x-model="chartName" required minlength="2" maxlength="200">
</div>
<div class="mb-3">
<div class="col-lg-6">
<label class="form-label small">Description</label>
<textarea class="form-control form-control-sm" name="description" rows="2" x-model="chartDescription"></textarea>
</div>
<div class="mb-3">
<div class="col-lg-4">
<label class="form-label small">Auto-refresh (dashboards)</label>
<select class="form-select form-select-sm" name="refresh_interval" x-model.number="refreshInterval">
<option :value="0">Manual</option>
@ -365,15 +363,13 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
<option :value="3600">1 hour</option>
</select>
</div>
</div>
<div class="d-flex justify-content-end mt-3 pt-2 border-top">
<button type="submit" class="btn btn-dark"><?= $mode === 'edit' ? 'Update chart' : 'Save chart' ?></button>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4 pt-2 border-top">
<button type="button" class="btn btn-outline-secondary" x-show="step > 1" @click="goStep(step - 1)">Back</button>
<button type="button" class="btn btn-dark" x-show="step < 5" @click.prevent="nextStep()">Next</button>
</div>
</form>
</div>
</div>
@ -387,7 +383,7 @@ document.getElementById('cbExportPng')?.addEventListener('click', async function
const el = document.getElementById('cb-apex-preview');
const c = el && el._apexChart;
if (!c || typeof c.dataURI !== 'function') {
alert('Run a live Apex preview on step 5 first.');
alert('Run a live Apex preview first.');
return;
}
try {

View File

@ -164,14 +164,6 @@
return {
boot,
step: 1,
steps: [
{ n: 1, label: 'Query' },
{ n: 2, label: 'Type' },
{ n: 3, label: 'Fields' },
{ n: 4, label: 'Style' },
{ n: 5, label: 'Preview & save' },
],
savedQueryId: boot.savedQueryId || '',
chartType: boot.chartType || 'line',
chartName: boot.name || '',
@ -191,6 +183,10 @@
previewError: '',
renderLoading: false,
renderError: '',
renderMeta: '',
autoRunPreview: true,
recommendedTypes: [],
_renderTimer: null,
chartTypes: [
{ id: 'line', label: 'Line', icon: '📈' },
{ id: 'spline', label: 'Spline', icon: '〰️' },
@ -221,6 +217,17 @@
}
},
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)) {
@ -255,7 +262,9 @@
this.previewRows = [];
this.previewMeta = '';
this.previewError = '';
this.recommendedTypes = [];
this.loadVariables();
this.queueRenderPreview();
},
/**
@ -313,9 +322,12 @@
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) {
@ -325,6 +337,91 @@
}
},
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');
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') {
this.display.secondary_y_field = setIf(this.display.secondary_y_field, nums[1] || '');
}
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 === '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 && this.display.secondary_y_field);
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',
@ -452,12 +549,15 @@
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;
}

View File

@ -13,6 +13,11 @@
color: var(--cb-cb-ink);
}
.cb-chart-builder details > summary {
cursor: pointer;
color: #334155;
}
.cb-cb-stepbar {
display: flex;
align-items: center;
@ -136,18 +141,32 @@
color: #fff;
}
.cb-cb-type--compact {
width: auto;
min-width: 90px;
padding: 0.45rem 0.7rem;
border-width: 1px;
border-color: #dbe5f1;
font-size: 0.72rem;
}
.cb-cb-type-ico {
font-size: 1.25rem;
line-height: 1;
}
.cb-cb-preview-host {
min-height: 320px;
min-height: 420px;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
border-radius: 0.5rem;
border: 1px dashed var(--cb-cb-border);
}
.cb-cb-preview-card {
position: sticky;
top: calc(var(--topbar-height) + 14px);
}
.cb-cb-hint {
display: flex;
gap: 0.75rem;
@ -159,6 +178,22 @@
line-height: 1.5;
}
.cb-cb-card .accordion-button {
font-size: 0.86rem;
font-weight: 600;
}
.cb-cb-card .accordion-button:not(.collapsed) {
color: #0f172a;
background: #f8fafc;
}
.cb-cb-card .accordion-item {
border: 1px solid var(--cb-cb-border);
border-radius: 0.6rem;
overflow: hidden;
}
.cb-cb-fm-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
@ -215,4 +250,13 @@
.cb-cb-fm-row {
grid-template-columns: 1fr;
}
.cb-cb-preview-host {
min-height: 320px;
}
.cb-cb-preview-card {
position: static;
top: auto;
}
}