GWM : chart improvemnet
This commit is contained in:
parent
ade83e50bb
commit
f9f935e78e
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user