GWM : ui improvement

This commit is contained in:
Gowtham M 2026-04-10 12:10:43 +05:30
parent 5deb9ce809
commit 6a4c35b0be
11 changed files with 743 additions and 194 deletions

View File

@ -75,7 +75,7 @@ class ChartController extends BaseController
$rules = [
'name' => 'required|min_length[2]|max_length[200]',
'saved_query_id' => 'required|integer',
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area]',
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area,ranked_progress]',
'x_field' => 'permit_empty|max_length[150]',
'y_field' => 'permit_empty|max_length[150]',
'group_field' => 'permit_empty|max_length[150]',
@ -155,7 +155,7 @@ class ChartController extends BaseController
$rules = [
'name' => 'required|min_length[2]|max_length[200]',
'saved_query_id' => 'required|integer',
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area]',
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area,ranked_progress]',
'x_field' => 'permit_empty|max_length[150]',
'y_field' => 'permit_empty|max_length[150]',
'group_field' => 'permit_empty|max_length[150]',
@ -533,6 +533,7 @@ class ChartController extends BaseController
'x_axis_label', 'y_axis_label', 'y_min', 'y_max', 'stacked',
'smooth', 'stepline', 'horizontal_bar', 'legend_position',
'secondary_y_field', 'gauge_max', 'kpi_columns', 'table_preview_limit',
'ranked_progress_limit', 'ranked_progress_footer_count',
];
$out = [];
@ -557,6 +558,13 @@ class ChartController extends BaseController
$out['palette_colors'] = array_slice($clean, 0, 12);
}
if (isset($out['ranked_progress_limit'])) {
$out['ranked_progress_limit'] = max(1, min(200, (int) $out['ranked_progress_limit']));
}
if (isset($out['ranked_progress_footer_count'])) {
$out['ranked_progress_footer_count'] = max(0, min(5, (int) $out['ranked_progress_footer_count']));
}
return $out;
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
* Adds ranked_progress chart type (horizontal ranked progress bars).
*/
class AddRankedProgressChartType extends Migration
{
public function up(): void
{
$this->db->query("ALTER TABLE `charts` MODIFY `chart_type` ENUM(
'bar','line','area','pie','donut',
'scatter','table','kpi_card','funnel',
'gauge','heatmap','combo',
'spline','stepline','radar','bubble','polar_area',
'ranked_progress'
) NOT NULL");
}
public function down(): void
{
// Dropping enum values can fail if in use; leave extended ENUM.
}
}

View File

@ -125,6 +125,10 @@ class ChartRenderer
return array_merge($this->buildKpiPayload($chart, $rows, $display), ['format_hints' => $hints]);
}
if ($type === 'ranked_progress') {
return array_merge($this->buildRankedProgressPayload($chart, $rows, $display), ['format_hints' => $hints]);
}
$options = $this->buildApexOptions($chart, $rows, $display);
if ($options === null) {
return [
@ -150,7 +154,7 @@ class ChartRenderer
public function buildApexOptions(array $chart, array $rows, array $display): ?array
{
$type = (string) ($chart['chart_type'] ?? 'line');
if ($rows === [] || $type === 'table' || $type === 'kpi_card') {
if ($rows === [] || $type === 'table' || $type === 'kpi_card' || $type === 'ranked_progress') {
return null;
}
@ -252,8 +256,8 @@ class ChartRenderer
}
$data = [];
foreach ($rows as $idx => $r) {
$xNum = $this->coercePlotNumber($r[$x] ?? null);
$yNum = $this->coercePlotNumber($r[$y] ?? null);
$xNum = $this->coercePlotNumber($this->rowField($r, $x));
$yNum = $this->coercePlotNumber($this->rowField($r, $y));
// Bubble charts are often switched from categorical charts in builder.
// When X is not numeric, use row index so points still render.
@ -270,7 +274,7 @@ class ChartRenderer
'y' => (float) $yNum,
];
if ($type === 'bubble') {
$zRaw = $this->coercePlotNumber($r[$valueF] ?? null);
$zRaw = $this->coercePlotNumber($this->rowField($r, $valueF));
$z = $zRaw !== null ? (float) $zRaw : 0.0;
$pt['z'] = $z > 0 ? $z : 1.0;
}
@ -282,25 +286,46 @@ class ChartRenderer
$scatterType = $type === 'bubble' ? 'bubble' : 'scatter';
return [
// Coordinate pairs need numeric axes; default category x-axis breaks scatter/bubble rendering.
$xaxisScatter = [
'type' => 'numeric',
'title' => ['text' => (string) ($display['x_axis_label'] ?? $x), 'style' => ['color' => '#64748b']],
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
'axisBorder' => ['show' => false],
'axisTicks' => ['show' => false],
];
$yaxisScatter = [
'type' => 'numeric',
'title' => ['text' => (string) ($display['y_axis_label'] ?? $y), 'style' => ['color' => '#64748b']],
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
];
$out = [
'chart' => array_merge($chartBase, ['type' => $scatterType, 'height' => 360, 'zoom' => ['enabled' => true, 'type' => 'xy']]),
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
'series' => [['name' => $y, 'data' => $data]],
'colors' => [$colors[0]],
'grid' => $grid,
'xaxis' => [
'title' => ['text' => (string) ($display['x_axis_label'] ?? $x), 'style' => ['color' => '#64748b']],
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
'axisBorder' => ['show' => false],
'axisTicks' => ['show' => false],
],
'yaxis' => [
'title' => ['text' => (string) ($display['y_axis_label'] ?? $y), 'style' => ['color' => '#64748b']],
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
],
'markers' => ['size' => 6],
'tooltip' => ['theme' => 'light'],
'xaxis' => $xaxisScatter,
'yaxis' => $yaxisScatter,
'tooltip' => ['theme' => 'light', 'shared' => false, 'intersect' => true],
];
if ($type === 'scatter') {
$out['markers'] = ['size' => 6];
} else {
$out['markers'] = ['opacity' => 0.85];
$out['fill'] = ['opacity' => 0.8];
$out['stroke'] = ['width' => 0];
$out['plotOptions'] = [
'bubble' => [
'minBubbleRadius' => 4,
'maxBubbleRadius' => 36,
],
];
}
return $out;
}
if ($type === 'radar') {
@ -653,6 +678,88 @@ class ChartRenderer
return $yaxisOpt;
}
/**
* Horizontal ranked progress bars: category (X) + numeric value (Y), aggregated per label.
*
* @param array<string, mixed> $chart
* @param array<int, array<string, mixed>> $rows
* @param array<string, mixed> $display
* @return array<string, mixed>
*/
private function buildRankedProgressPayload(array $chart, array $rows, array $display): array
{
$x = trim((string) ($chart['x_field'] ?? ''));
$y = trim((string) ($chart['y_field'] ?? ''));
if ($x === '' || $y === '' || $rows === []) {
return [
'engine' => 'empty',
'message' => 'Ranked progress needs X (category) and Y (numeric value) fields.',
];
}
$aggregates = [];
foreach ($rows as $r) {
$label = $this->stringify($this->rowField($r, $x));
if ($label === '') {
continue;
}
$aggregates[$label] = ($aggregates[$label] ?? 0.0) + (float) $this->coerceNumber($this->rowField($r, $y));
}
if ($aggregates === []) {
return [
'engine' => 'empty',
'message' => 'No data to render this chart.',
];
}
arsort($aggregates, SORT_NUMERIC);
$limit = isset($display['ranked_progress_limit']) ? max(1, min(200, (int) $display['ranked_progress_limit'])) : 50;
$aggregates = array_slice($aggregates, 0, $limit, true);
$maxVal = max($aggregates);
$total = array_sum($aggregates);
$total = abs($total) < 1e-12 ? 0.0 : $total;
$palette = $this->ensureColorCount($this->resolveColors($display), max(count($aggregates), 1));
$items = [];
$i = 0;
foreach ($aggregates as $label => $val) {
$color = $palette[$i % count($palette)];
$barPct = $maxVal > 0 ? round(($val / $maxVal) * 100, 4) : 0.0;
$items[] = [
'label' => $label,
'value' => $val,
'value_label' => $this->formatNumber($val, $display),
'color' => $color,
'bar_pct' => $barPct,
];
$i++;
}
$footerCount = isset($display['ranked_progress_footer_count']) ? max(0, min(5, (int) $display['ranked_progress_footer_count'])) : 2;
$footer = [];
if ($footerCount > 0 && $total > 0) {
foreach (array_slice($items, 0, $footerCount) as $row) {
$footer[] = [
'label' => $row['label'],
'pct' => (int) round(($row['value'] / $total) * 100),
'color' => $row['color'],
];
}
}
return [
'engine' => 'ranked_progress',
'title' => (string) ($display['title'] ?? ''),
'subtitle' => (string) ($display['subtitle'] ?? ''),
'items' => $items,
'footer' => $footer,
];
}
/**
* @param array<string, mixed> $display
* @param array<int, array<string, mixed>> $rows
@ -948,6 +1055,24 @@ class ChartRenderer
return null;
}
/**
* @param array<string, mixed>|object $row
*/
private function rowField(mixed $row, string $key): mixed
{
if ($key === '') {
return null;
}
if (is_array($row)) {
return $row[$key] ?? null;
}
if (is_object($row)) {
return $row->{$key} ?? null;
}
return null;
}
/**
* @param array<string, mixed> $display
*/

View File

@ -52,7 +52,7 @@ class ChartModel extends Model
'workspace_id' => 'required|integer',
'data_source_id' => 'required|integer',
'name' => 'required|min_length[2]|max_length[200]',
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area]',
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area,ranked_progress]',
'query_type' => 'permit_empty|in_list[visual,raw_sql,api]',
];

View File

@ -86,11 +86,28 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
<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="row g-2">
<div class="cb-cb-var-grid">
<template x-for="v in variableDefs" :key="v.name">
<div class="col-md-6">
<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="queueRenderPreview()">
<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>

View File

@ -251,6 +251,42 @@
el.innerHTML = html || '<p class="text-muted small mb-0">No KPI columns.</p>';
return;
}
if (payload.engine === 'ranked_progress') {
const items = payload.items || [];
const footer = payload.footer || [];
let html = '<div class="cb-ranked-progress cb-ranked-progress--builder">';
if (payload.title) {
html += '<div class="cb-ranked-progress-title">' + escapeHtml(payload.title) + '</div>';
}
if (payload.subtitle) {
html += '<div class="cb-ranked-progress-subtitle text-muted small">' + escapeHtml(payload.subtitle) + '</div>';
}
items.forEach((it) => {
const pct = Math.max(0, Math.min(100, Number(it.bar_pct) || 0));
const col = String(it.color || '#36a2eb');
html += '<div class="cb-ranked-row">';
html += '<span class="cb-ranked-dot" style="background-color:' + escapeHtml(col) + '"></span>';
html += '<span class="cb-ranked-label">' + escapeHtml(it.label) + '</span>';
html += '<div class="cb-ranked-bar-wrap"><div class="cb-ranked-bar-track">';
html += '<div class="cb-ranked-bar-fill" style="width:' + pct + '%;background-color:' + escapeHtml(col) + '"></div>';
html += '</div></div>';
html += '<span class="cb-ranked-val">' + escapeHtml(it.value_label != null ? String(it.value_label) : String(it.value)) + '</span>';
html += '</div>';
});
if (footer.length) {
html += '<div class="cb-ranked-footer">';
footer.forEach((f) => {
const c = String(f.color || '#64748b');
html += '<span class="cb-ranked-pill" style="border-color:' + escapeHtml(c) + '40;color:' + escapeHtml(c) + '">';
html += '<span class="cb-ranked-pill-dot" style="background:' + escapeHtml(c) + '"></span>';
html += escapeHtml(f.label) + ' ' + (f.pct != null ? f.pct : 0) + '%</span>';
});
html += '</div>';
}
html += '</div>';
el.innerHTML = html;
return;
}
if (payload.engine === 'apex' && payload.options && typeof ApexCharts !== 'undefined') {
const opts = JSON.parse(JSON.stringify(payload.options));
applyFormatHints(opts, hints || payload.format_hints);
@ -321,6 +357,7 @@
{ id: 'stepline', label: 'Step line', icon: '📶' },
{ id: 'area', label: 'Area', icon: '📉' },
{ id: 'bar', label: 'Bar', icon: '📊' },
{ id: 'ranked_progress', label: 'Ranked list', icon: '📋' },
{ id: 'combo', label: 'Combo', icon: '⚡' },
{ id: 'pie', label: 'Pie', icon: '🥧' },
{ id: 'donut', label: 'Donut', icon: '🍩' },
@ -491,7 +528,7 @@
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) out.push('bar', 'ranked_progress');
if (cats.length && nums.length === 1) out.push('donut', 'pie');
if (nums.length >= 2) out.push('scatter');
out.push('table', 'kpi_card');
@ -556,7 +593,7 @@
return [
'bar', 'line', 'area', 'spline', 'stepline',
'pie', 'donut', 'polar_area', 'scatter', 'bubble', 'radar',
'funnel', 'heatmap', 'combo',
'funnel', 'heatmap', 'combo', 'ranked_progress',
].includes(this.chartType);
},
@ -572,6 +609,7 @@
stepline: 'Step line: values change in steps; same mapping as line.',
area: 'Area: same as line; great for volume over time.',
bar: 'Bar: X = category, Y = numeric. Toggle horizontal in Style.',
ranked_progress: 'Ranked list: X = category label, Y = numeric value. Rows aggregate by label; bars scale to the largest value; footer shows top shares of total.',
combo: 'Combo: columns from Y, line from second metric. Pick Line series in Fields.',
pie: 'Pie: X = slice labels, Y = numeric values.',
donut: 'Donut: same as pie with a center cutout.',

View File

@ -17,44 +17,60 @@
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if ($charts === []): ?>
<div class="cb-alert-empty-panel">
<div>
<span class="material-symbols-outlined cb-alert-empty-icon d-block">bar_chart</span>
<p class="cb-alert-empty-msg">No charts yet. Create a query first, then build a chart from it.</p>
<a href="<?= base_url('chart/create') ?>" class="btn cb-page-primary-btn mt-3">Create chart</a>
</div>
</div>
<?php else: ?>
<div class="cb-chart-grid">
<?php foreach ($charts as $c): ?>
<div class="cb-chart-card h-100">
<div class="cb-chart-card-accent"></div>
<div class="cb-chart-card-inner">
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<div class="min-w-0">
<div class="cb-chart-card-name"><?= esc($c['name']) ?></div>
<div class="cb-chart-meta"><?= esc($c['saved_query_name'] ?? 'Query') ?> · <?= esc($c['data_source_name'] ?? '') ?></div>
<div class="cb-table-shell">
<div class="table-responsive">
<table class="table cb-data-table align-middle">
<thead>
<tr>
<th>Name</th>
<th>Data Source</th>
<th>Chart type</th>
<th>Updated</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<?php if ($charts === []): ?>
<tr>
<td colspan="5" class="text-center text-muted py-5">No charts yet. Create a query first, then build a chart from it.</td>
</tr>
<?php endif; ?>
<?php foreach ($charts as $c): ?>
<?php
$ct = strtoupper((string) ($c['chart_type'] ?? ''));
$typeClass = match ($ct) {
'TABLE' => 'cb-badge-type--sql',
default => 'cb-badge-type--visual',
};
?>
<tr>
<td>
<div class="cb-cell-name"><?= esc($c['name']) ?></div>
<div class="cb-cell-sub"><?= esc($c['saved_query_name'] ?? 'Query') ?></div>
</td>
<td><?= esc($c['data_source_name'] ?: '—') ?></td>
<td><span class="cb-badge-type <?= esc($typeClass, 'attr') ?>"><?= esc($ct) ?></span></td>
<td class="text-muted small"><?= esc($c['updated_at'] ?? '—') ?></td>
<td class="text-end">
<div class="cb-table-actions">
<button type="button" class="cb-action-btn cb-action-btn--ghost" data-bs-toggle="modal" data-bs-target="#modalShare" data-share-type="chart" data-resource-id="<?= (int) $c['id'] ?>">Share</button>
<a href="<?= base_url('chart/edit/' . (int) $c['id']) ?>" class="cb-action-btn cb-action-btn--edit">Edit</a>
<form action="<?= base_url('chart/duplicate/' . (int) $c['id']) ?>" method="post" class="d-inline">
<?= csrf_field() ?>
<button type="submit" class="cb-action-btn cb-action-btn--ghost">Duplicate</button>
</form>
<form action="<?= base_url('chart/delete/' . (int) $c['id']) ?>" method="post" class="d-inline" onsubmit="return confirm('Delete this chart?');">
<?= csrf_field() ?>
<button type="submit" class="cb-action-btn cb-action-btn--delete">Delete</button>
</form>
</div>
<span class="cb-chart-type-badge flex-shrink-0"><?= esc($c['chart_type']) ?></span>
</div>
<div class="cb-chart-meta mb-0">Updated <?= esc($c['updated_at'] ?? '') ?></div>
<div class="cb-chart-card-actions">
<button type="button" class="btn btn-sm cb-chart-btn-share" data-bs-toggle="modal" data-bs-target="#modalShare" data-share-type="chart" data-resource-id="<?= (int) $c['id'] ?>">Share</button>
<a href="<?= base_url('chart/edit/' . (int) $c['id']) ?>" class="btn btn-sm btn-primary cb-chart-btn-edit">Edit</a>
<form action="<?= base_url('chart/duplicate/' . (int) $c['id']) ?>" method="post" class="d-inline">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm cb-chart-btn-dup">Duplicate</button>
</form>
<form action="<?= base_url('chart/delete/' . (int) $c['id']) ?>" method="post" class="d-inline" onsubmit="return confirm('Delete this chart?');">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm cb-chart-btn-del">Delete</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?= $this->endSection() ?>

View File

@ -174,6 +174,7 @@
el._apexChart = null;
}
el.innerHTML = '';
el.classList.remove('cb-dash-table-host', 'cb-ranked-progress-host');
if (!payload || payload.engine === 'empty') {
el.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 text-muted small p-4">' + escapeHtml(payload?.message || 'No data') + '</div>';
return;
@ -289,6 +290,43 @@
el.innerHTML = html || '<p class="text-muted small mb-0">No KPI data.</p>';
return;
}
if (payload.engine === 'ranked_progress') {
const items = payload.items || [];
const footer = payload.footer || [];
let html = '<div class="cb-ranked-progress cb-ranked-progress--dash">';
if (payload.title) {
html += '<div class="cb-ranked-progress-title">' + escapeHtml(payload.title) + '</div>';
}
if (payload.subtitle) {
html += '<div class="cb-ranked-progress-subtitle text-muted small">' + escapeHtml(payload.subtitle) + '</div>';
}
items.forEach((it) => {
const pct = Math.max(0, Math.min(100, Number(it.bar_pct) || 0));
const col = String(it.color || '#36a2eb');
html += '<div class="cb-ranked-row">';
html += '<span class="cb-ranked-dot" style="background-color:' + escapeHtml(col) + '"></span>';
html += '<span class="cb-ranked-label">' + escapeHtml(it.label) + '</span>';
html += '<div class="cb-ranked-bar-wrap"><div class="cb-ranked-bar-track">';
html += '<div class="cb-ranked-bar-fill" style="width:' + pct + '%;background-color:' + escapeHtml(col) + '"></div>';
html += '</div></div>';
html += '<span class="cb-ranked-val">' + escapeHtml(it.value_label != null ? String(it.value_label) : String(it.value)) + '</span>';
html += '</div>';
});
if (footer.length) {
html += '<div class="cb-ranked-footer">';
footer.forEach((f) => {
const c = String(f.color || '#64748b');
html += '<span class="cb-ranked-pill" style="border-color:' + escapeHtml(c) + '40;color:' + escapeHtml(c) + '">';
html += '<span class="cb-ranked-pill-dot" style="background:' + escapeHtml(c) + '"></span>';
html += escapeHtml(f.label) + ' ' + (f.pct != null ? f.pct : 0) + '%</span>';
});
html += '</div>';
}
html += '</div>';
el.innerHTML = html;
el.classList.add('cb-ranked-progress-host');
return;
}
if (payload.engine === 'apex' && payload.options && typeof ApexCharts !== 'undefined') {
const opts = JSON.parse(JSON.stringify(payload.options));
applyFormatHints(opts, hints || payload.format_hints);
@ -386,12 +424,25 @@
if (globalVars[k] === undefined && def.default_value != null) globalVars[k] = String(def.default_value);
const current = globalVars[k] ?? '';
const labelHtml = '<label class="cb-dash-var-label">' + escapeHtml(lab) + '</label>';
const wrapPill = (innerHtml, val, canClear) => {
const filled = String(val ?? '').trim() !== '';
let h = '<div class="cb-dash-var-item"><div class="cb-dash-var-pill' + (filled ? ' is-filled' : '') + '">';
h += '<span class="cb-dash-var-key" title="' + escapeHtml(lab) + '">' + escapeHtml(lab) + '</span>';
h += innerHtml;
if (canClear) {
h += '<button type="button" class="cb-dash-var-clear' + (filled ? '' : ' d-none') + '" data-cb-var-clear="' + escapeHtml(k) + '" aria-label="Clear variable"><span class="material-symbols-outlined">close</span></button>';
}
h += '</div></div>';
return h;
};
if (def.type === 'number') {
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="number" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
const inp = '<input type="number" class="cb-dash-var-input" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '>';
return wrapPill(inp, current, true);
}
if (def.type === 'date') {
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="date" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
const inp = '<input type="date" class="cb-dash-var-input" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '>';
return wrapPill(inp, current, true);
}
if (def.type === 'date_range') {
const parts = String(current || '').split('|');
@ -402,7 +453,7 @@
const currentSet = def.type === 'multi_select'
? new Set((Array.isArray(current) ? current : String(current).split(',')).map((x) => String(x).trim()).filter(Boolean))
: null;
let h = '<div class="cb-dash-var-item">' + labelHtml + '<select class="form-select form-select-sm" data-cb-var-key="' + escapeHtml(k) + '"' + (def.type === 'multi_select' ? ' multiple' : '') + '>';
let h = '<select class="cb-dash-var-input cb-dash-var-select" data-cb-var-key="' + escapeHtml(k) + '"' + (def.type === 'multi_select' ? ' multiple' : '') + '>';
if (def.type === 'select') h += '<option value="">—</option>';
opts.forEach((o) => {
const v = typeof o === 'object' ? (o.value ?? o.label) : o;
@ -413,10 +464,11 @@
: (String(current) === vv ? ' selected' : '');
h += '<option value="' + escapeHtml(vv) + '"' + sel + '>' + escapeHtml(String(t)) + '</option>';
});
h += '</select></div>';
return h;
h += '</select>';
return wrapPill(h, current, def.type !== 'multi_select');
}
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="text" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
const txt = '<input type="text" class="cb-dash-var-input" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '>';
return wrapPill(txt, current, true);
}
async function loadDashboardVariables() {
@ -451,18 +503,32 @@
function bindVarInputs(root) {
if (!root) return;
const toggleFilledState = (inp) => {
const pill = inp.closest('.cb-dash-var-pill');
if (!pill) return;
const filled = inp.multiple
? Array.from(inp.selectedOptions || []).some((o) => String(o.value || '').trim() !== '')
: String(inp.value || '').trim() !== '';
pill.classList.toggle('is-filled', filled);
const clearBtn = pill.querySelector('[data-cb-var-clear]');
if (clearBtn) clearBtn.classList.toggle('d-none', !filled);
};
root.querySelectorAll('[data-cb-var-key]').forEach((inp) => {
const key = inp.getAttribute('data-cb-var-key');
const apply = () => {
if (!inp.multiple) {
setGlobalVar(key, inp.value);
toggleFilledState(inp);
return;
}
const vals = Array.from(inp.selectedOptions || []).map((o) => o.value);
setGlobalVar(key, vals);
toggleFilledState(inp);
};
inp.addEventListener('change', apply);
inp.addEventListener('input', apply);
toggleFilledState(inp);
});
root.querySelectorAll('[data-cb-var-range]').forEach((inp) => {
inp.addEventListener('change', () => {
@ -472,6 +538,24 @@
setGlobalVar(parent, start + '|' + end);
});
});
root.querySelectorAll('[data-cb-var-clear]').forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-cb-var-clear');
const inp = root.querySelector('[data-cb-var-key="' + key + '"]');
if (!inp) return;
if (inp.multiple) {
Array.from(inp.options || []).forEach((o) => { o.selected = false; });
setGlobalVar(key, []);
} else {
inp.value = '';
setGlobalVar(key, '');
}
const pill = inp.closest('.cb-dash-var-pill');
if (pill) pill.classList.remove('is-filled');
btn.classList.add('d-none');
inp.focus();
});
});
}
function renderDashboardVariablePanel() {
@ -1226,11 +1310,38 @@
window.location.reload();
});
let fs = false;
document.getElementById('cbBtnFs')?.addEventListener('click', () => {
fs = !fs;
document.body.classList.toggle('cb-dash-fs', fs);
});
const fsBtn = document.getElementById('cbBtnFs');
function isElementFullscreen() {
return !!document.fullscreenElement;
}
function setFsButtonState() {
if (!fsBtn) return;
const icon = fsBtn.querySelector('.material-symbols-outlined');
const active = isElementFullscreen();
fsBtn.title = active ? 'Exit fullscreen' : 'Fullscreen';
if (icon) {
icon.textContent = active ? 'fullscreen_exit' : 'fullscreen';
}
}
async function toggleDashboardFullscreen() {
const shellEl = document.getElementById('cbDashShell');
if (!shellEl) return;
try {
if (!isElementFullscreen()) {
await shellEl.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (e) {
// Fallback for older browsers/contexts where Fullscreen API is unavailable.
document.body.classList.toggle('cb-dash-fs');
} finally {
setFsButtonState();
}
}
fsBtn?.addEventListener('click', () => { toggleDashboardFullscreen(); });
document.addEventListener('fullscreenchange', setFsButtonState);
setFsButtonState();
/* Add widget modal */
const addType = document.getElementById('cbAddType');

View File

@ -309,6 +309,84 @@ body.cb-cb-preview-fs-open {
white-space: nowrap;
}
.cb-cb-var-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
}
.cb-cb-var-pill {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 4px 8px 4px 10px;
border: 1px solid #dbe5f1;
border-radius: 10px;
background: #fff;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.cb-cb-var-pill:focus-within {
border-color: #93c5fd;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.cb-cb-var-key {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: #334155;
white-space: nowrap;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
}
.cb-cb-var-input {
flex: 1 1 auto;
min-width: 60px;
border: 0;
outline: 0;
background: transparent;
font-size: 14px;
color: #0f172a;
padding: 0;
}
.cb-cb-var-input::placeholder {
color: #94a3b8;
}
.cb-cb-var-pill.is-filled .cb-cb-var-input {
color: #2563eb;
font-weight: 600;
}
.cb-cb-var-clear {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: 0;
border-radius: 999px;
background: transparent;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cb-cb-var-clear:hover {
background: #eff6ff;
color: #2563eb;
}
.cb-cb-var-clear .material-symbols-outlined {
font-size: 16px;
line-height: 1;
}
.cb-cb-hint {
display: flex;
gap: 0.75rem;
@ -455,3 +533,104 @@ body.cb-cb-preview-fs-open {
justify-content: flex-end;
}
}
/* Ranked list (horizontal progress bars) */
.cb-ranked-progress {
font-size: 13px;
color: #0f172a;
width: 100%;
}
.cb-ranked-progress--builder {
padding: 4px 2px;
}
.cb-ranked-progress-title {
font-weight: 700;
font-size: 15px;
margin-bottom: 4px;
}
.cb-ranked-progress-subtitle {
margin-bottom: 12px;
}
.cb-ranked-row {
display: grid;
grid-template-columns: 10px minmax(72px, 1fr) minmax(80px, 3fr) auto;
align-items: center;
gap: 10px 12px;
padding: 10px 0;
border-bottom: 1px solid #e8ecf1;
}
.cb-ranked-row:last-of-type {
border-bottom: none;
}
.cb-ranked-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.cb-ranked-label {
font-weight: 600;
color: #334155;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cb-ranked-bar-wrap {
min-width: 0;
}
.cb-ranked-bar-track {
height: 12px;
border-radius: 999px;
background: #e8ecf1;
overflow: hidden;
}
.cb-ranked-bar-fill {
height: 100%;
border-radius: 999px;
transition: width 0.25s ease;
}
.cb-ranked-val {
font-variant-numeric: tabular-nums;
font-weight: 700;
color: #0f172a;
text-align: right;
min-width: 3ch;
}
.cb-ranked-footer {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
padding-top: 4px;
}
.cb-ranked-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid #cbd5e1;
background: #f8fafc;
font-size: 12px;
font-weight: 600;
}
.cb-ranked-pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}

View File

@ -195,7 +195,6 @@
backdrop-filter: blur(10px);
border: 1px solid #e5e7eb;
border-radius: 14px;
margin-bottom: 18px;
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
}
@ -359,6 +358,84 @@
gap: 5px;
}
.cb-dash-global-vars .cb-dash-var-pill {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 4px 8px 4px 10px;
border: 1px solid #dbe5f1;
border-radius: 10px;
background: #fff;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.cb-dash-global-vars .cb-dash-var-pill:focus-within {
border-color: #93c5fd;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.cb-dash-global-vars .cb-dash-var-key {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: #334155;
white-space: nowrap;
max-width: 42%;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.cb-dash-global-vars .cb-dash-var-input {
flex: 1 1 auto;
min-width: 60px;
border: 0;
outline: 0;
background: transparent;
font-size: 14px;
color: #0f172a;
padding: 0;
}
.cb-dash-global-vars .cb-dash-var-input::placeholder {
color: #94a3b8;
}
.cb-dash-global-vars .cb-dash-var-pill.is-filled .cb-dash-var-input {
color: #2563eb;
font-weight: 600;
}
.cb-dash-global-vars .cb-dash-var-select {
padding-right: 18px;
}
.cb-dash-global-vars .cb-dash-var-clear {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: 0;
border-radius: 999px;
background: transparent;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cb-dash-global-vars .cb-dash-var-clear:hover {
background: #eff6ff;
color: #2563eb;
}
.cb-dash-global-vars .cb-dash-var-clear .material-symbols-outlined {
font-size: 16px;
line-height: 1;
}
.cb-dash-global-vars .cb-dash-var-label {
font-size: 11px;
font-weight: 700;
@ -381,6 +458,23 @@
color: #cbd5e1;
}
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-pill {
background: rgba(15, 23, 42, 0.7);
border-color: rgba(148, 163, 184, 0.35);
}
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-key {
color: #cbd5e1;
}
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-input {
color: #e2e8f0;
}
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-pill.is-filled .cb-dash-var-input {
color: #93c5fd;
}
.cb-dash-grid-wrap .grid-stack {
width: 100%;
min-width: 0;
@ -503,6 +597,40 @@
overflow: auto;
}
.cb-dash-widget-body .cb-dash-chart-inner > div[id^="cb-chart-"].cb-ranked-progress-host {
overflow: auto;
min-height: 0;
}
.cb-ranked-progress-host .cb-ranked-progress--dash {
padding-right: 2px;
}
.cb-dash-shell.cb-theme-dark .cb-ranked-progress {
color: #f1f5f9;
}
.cb-dash-shell.cb-theme-dark .cb-ranked-row {
border-bottom-color: rgba(148, 163, 184, 0.2);
}
.cb-dash-shell.cb-theme-dark .cb-ranked-label {
color: #e2e8f0;
}
.cb-dash-shell.cb-theme-dark .cb-ranked-val {
color: #f8fafc;
}
.cb-dash-shell.cb-theme-dark .cb-ranked-bar-track {
background: rgba(148, 163, 184, 0.25);
}
.cb-dash-shell.cb-theme-dark .cb-ranked-pill {
background: rgba(30, 41, 59, 0.85);
border-color: rgba(148, 163, 184, 0.35);
}
.cb-dash-widget-edit,
.cb-dash-widget-remove {
display: inline-flex;
@ -806,6 +934,25 @@ body.cb-dash-fs .cb-dash-shell {
padding: 16px;
}
/* Native Fullscreen API mode: keep dashboard scrollable */
#cbDashShell:fullscreen,
#cbDashShell:-webkit-full-screen {
width: 100%;
height: 100vh;
min-height: 100vh;
margin: 0;
padding: 16px;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
background: #f8fafc;
}
#cbDashShell.cb-theme-dark:fullscreen,
#cbDashShell.cb-theme-dark:-webkit-full-screen {
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
}
.grid-stack.cb-dash-editing .grid-stack-item {
cursor: grab;
}

View File

@ -863,124 +863,6 @@ button.cb-action-btn {
color: #7c3aed;
}
/* —— Charts list —— */
.cb-chart-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 1199.98px) {
.cb-chart-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 767.98px) {
.cb-chart-grid {
grid-template-columns: 1fr;
}
}
.cb-chart-card {
position: relative;
background: var(--card-bg);
border: var(--card-border);
border-radius: var(--card-radius);
padding: 0;
overflow: hidden;
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.cb-chart-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
border-color: #d0d3dc;
}
.cb-chart-card-accent {
display: block;
height: 3px;
width: 100%;
background: var(--accent-blue);
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.cb-chart-card-inner {
padding: 16px 20px 20px;
}
.cb-chart-type-badge {
background: #f3f4f6;
color: #374151;
border-radius: 5px;
padding: 3px 9px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cb-chart-card-name {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.cb-chart-meta {
font-size: 12px;
color: var(--text-muted);
}
.cb-chart-card-actions {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #f0f1f3;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cb-chart-btn {
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
line-height: 1.25;
}
.cb-chart-btn-share,
.cb-chart-btn-dup {
border: 1px solid var(--divider-color);
background: #fff;
color: var(--text-secondary);
}
.cb-chart-btn-share:hover,
.cb-chart-btn-dup:hover {
background: #f9fafb;
}
.cb-chart-btn-edit.btn-primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
}
.cb-chart-btn-del {
border: 1px solid #fca5a5;
color: var(--danger-red);
background: #fff;
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
}
/* —— Alerts —— */
.cb-alert-panel {
background: var(--card-bg);
@ -1364,7 +1246,6 @@ html[data-cb-theme="dark"] .cb-page-title {
html[data-cb-theme="dark"] .cb-workspace-card,
html[data-cb-theme="dark"] .cb-table-shell,
html[data-cb-theme="dark"] .cb-form-shell,
html[data-cb-theme="dark"] .cb-chart-card,
html[data-cb-theme="dark"] .cb-alert-panel,
html[data-cb-theme="dark"] .cb-alert-empty-panel {
border-color: var(--cb-line);