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

459 lines
32 KiB
PHP

<?= $this->extend('layouts/main') ?>
<?php
$cRow = is_array($chart ?? null) ? $chart : [];
$bootstrap = [
'mode' => $mode,
'chartId' => isset($cRow['id']) ? (int) $cRow['id'] : null,
'savedQueryId' => (int) ($cRow['saved_query_id'] ?? $preselectQueryId ?? 0),
'chartType' => (string) ($cRow['chart_type'] ?? 'line'),
'name' => (string) ($cRow['name'] ?? ''),
'description' => (string) ($cRow['description'] ?? ''),
'xField' => (string) ($cRow['x_field'] ?? ''),
'yField' => (string) ($cRow['y_field'] ?? ''),
'groupField' => (string) ($cRow['group_field'] ?? ''),
'valueField' => (string) ($cRow['value_field'] ?? ''),
'refreshInterval' => (int) ($cRow['refresh_interval'] ?? 0),
'display' => $displayConfig ?? [],
];
$bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?: '{}';
?>
<?= $this->section('styles') ?>
<link rel="stylesheet" href="<?= base_url('assets/css/chart-builder.css') ?>">
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<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">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'])): ?>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#modalShare" data-share-type="chart" data-resource-id="<?= (int) $cRow['id'] ?>">Share</button>
<?php endif; ?>
<a href="<?= base_url('chart') ?>" class="btn cb-settings-back">All charts</a>
</div>
</header>
<div class="cb-page-body">
<script type="application/json" id="cb-bootstrap-json"><?= $bootstrapJson ?></script>
<div class="cb-chart-builder" x-data="chartBuilder(JSON.parse(document.getElementById('cb-bootstrap-json').textContent.trim()))" x-init="init()">
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<form id="chart-builder-form" method="post"
:action="boot.mode === 'edit' ? '<?= base_url('chart/update/') ?>' + boot.chartId : '<?= base_url('chart/store') ?>'"
@submit="beforeSubmit($event)">
<?= csrf_field() ?>
<input type="hidden" name="display_config" :value="displayJson()">
<input type="hidden" name="chart_type" :value="chartType">
<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">
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-b cb-cb-query-compact">
<div class="cb-cb-query-row">
<div class="cb-cb-query-main">
<label class="form-label small fw-semibold mb-1">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>
</div>
<div class="cb-cb-query-actions">
<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</span>
<span x-show="previewLoading" class="spinner-border spinner-border-sm"></span>
</button>
</div>
</div>
<div class="cb-cb-query-vars mt-2" x-show="variableDefs.length" x-cloak>
<div class="p-2 rounded-3 bg-light border">
<div class="small fw-bold mb-2">Query variables</div>
<div class="cb-cb-var-grid">
<template x-for="v in variableDefs" :key="v.name">
<div class="cb-cb-var-pill" :class="{ 'is-filled': String(variables[v.name] || '').trim() !== '' }">
<span class="cb-cb-var-key" x-text="v.label || v.name"></span>
<input
type="text"
class="cb-cb-var-input"
:placeholder="v.default_value || ''"
x-model="variables[v.name]"
@input="queueRenderPreview()"
>
<button
type="button"
class="cb-cb-var-clear"
x-show="String(variables[v.name] || '').trim() !== ''"
x-cloak
@click="variables[v.name] = ''; queueRenderPreview()"
title="Clear"
aria-label="Clear variable value"
>
<span class="material-symbols-outlined">close</span>
</button>
</div>
</template>
</div>
</div>
</div>
<p class="text-danger small mt-2 mb-0" x-show="previewError" x-text="previewError"></p>
</div>
</div>
<div class="row g-3 align-items-start cb-cb-main-row">
<div class="col-lg-4">
<div class="cb-cb-card cb-cb-config-card">
<div class="cb-cb-config-tabs">
<button type="button" class="cb-cb-tab-btn" :class="{ active: configTab === 'data' }" @click="configTab = 'data'">Data</button>
<button type="button" class="cb-cb-tab-btn" :class="{ active: configTab === 'display' }" @click="configTab = 'display'">Display</button>
</div>
<div class="cb-cb-card-b cb-cb-config-body">
<div x-show="configTab === 'data'" x-cloak>
<div class="cb-cb-section mb-3">
<div class="small fw-bold mb-2">Recommended charts</div>
<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-2">
<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-2"><span x-text="typeHint()"></span></div>
</div>
<div class="cb-cb-section">
<div class="small fw-bold mb-2">Field mapping</div>
<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>
<div x-show="configTab === 'display'" x-cloak>
<div class="cb-cb-section mb-3">
<div class="small fw-bold mb-2">Basic style</div>
<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>
<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 class="cb-cb-section">
<div class="small fw-bold mb-2">Advanced options</div>
<button type="button" class="cb-cb-advanced-toggle" data-bs-toggle="collapse" data-bs-target="#cbAdvancedStyleInline" aria-expanded="false" aria-controls="cbAdvancedStyleInline">
<span class="material-symbols-outlined">tune</span>
<span>Advanced style controls</span>
</button>
<div id="cbAdvancedStyleInline" class="collapse mt-2">
<div class="row g-3">
<div class="col-12">
<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-6">
<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-6">
<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-12">
<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>
<option value="rose">Rose</option>
<option value="teal">Teal</option>
<option value="amber">Amber</option>
<option value="indigo">Indigo</option>
<option value="cool_gray">Cool Gray</option>
</select>
</div>
</div>
<div class="mt-2" x-show="display.color_mode === 'custom'">
<label class="form-label small">Swatches</label>
<div class="cb-cb-swatch-grid">
<template x-for="idx in [0,1,2,3,4,5,6,7,8,9,10,11]" :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-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-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="small fw-semibold text-muted mt-3 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>
</div>
</div>
<div class="col-lg-8">
<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 d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" id="cbPreviewFsBtn" title="Fullscreen preview" aria-label="Fullscreen preview">
<span class="material-symbols-outlined" id="cbPreviewFsIcon" style="font-size:18px;">open_in_full</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" title="Refresh preview" aria-label="Refresh preview" @click.prevent="renderLivePreview()" :disabled="renderLoading || !columns.length">
<span class="material-symbols-outlined" style="font-size:18px;">refresh</span>
</button>
<?php if ($mode === 'edit' && ! empty($cRow['id'])): ?>
<button type="button" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" id="cbExportPng" title="Export PNG" aria-label="Export PNG">
<span class="material-symbols-outlined" style="font-size:18px;">image</span>
</button>
<a class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=csv') ?>" title="Export CSV" aria-label="Export CSV">
<span class="material-symbols-outlined" style="font-size:18px;">description</span>
</a>
<a class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=excel') ?>" title="Export Excel" aria-label="Export Excel">
<span class="material-symbols-outlined" style="font-size:18px;">table_view</span>
</a>
<?php endif; ?>
</div>
</div>
<div class="cb-cb-card-b">
<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>
<div class="cb-cb-card mt-3">
<div class="cb-cb-card-h"><span class="fw-bold">Save chart</span></div>
<div class="cb-cb-card-b">
<div class="row g-3 mt-1">
<div class="col-lg-8">
<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="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>
<option :value="60">Every minute</option>
<option :value="300">5 minutes</option>
<option :value="900">15 minutes</option>
<option :value="3600">1 hour</option>
</select>
</div>
</div>
<div class="row g-3 mt-1 align-items-end">
<div class="col-lg-9">
<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="col-lg-3 d-grid">
<button type="submit" class="btn btn-dark"><?= $mode === 'edit' ? 'Update chart' : 'Save chart' ?></button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js"></script>
<?= $this->include('chart/builder_scripts') ?>
<script>
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 first.');
return;
}
try {
const { imgURI } = await c.dataURI({ scale: 2, width: el.offsetWidth || undefined });
const res = await fetch(imgURI);
const blob = await res.blob();
if (typeof saveAs === 'function') saveAs(blob, 'chart.png');
else window.open(imgURI, '_blank');
} catch (e) {
alert('Could not export PNG.');
}
});
(function () {
const btn = document.getElementById('cbPreviewFsBtn');
const icon = document.getElementById('cbPreviewFsIcon');
const card = document.querySelector('.cb-cb-preview-card');
if (!btn || !icon || !card) return;
const sync = () => {
const open = card.classList.contains('cb-cb-preview-fullscreen');
icon.textContent = open ? 'close_fullscreen' : 'open_in_full';
btn.title = open ? 'Exit fullscreen' : 'Fullscreen preview';
btn.setAttribute('aria-label', btn.title);
document.body.classList.toggle('cb-cb-preview-fs-open', open);
};
btn.addEventListener('click', () => {
card.classList.toggle('cb-cb-preview-fullscreen');
sync();
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && card.classList.contains('cb-cb-preview-fullscreen')) {
card.classList.remove('cb-cb-preview-fullscreen');
sync();
}
});
})();
</script>
<?= $this->endSection() ?>