diff --git a/app/Controllers/Chart/ChartController.php b/app/Controllers/Chart/ChartController.php index 26d1934..574dd73 100644 --- a/app/Controllers/Chart/ChartController.php +++ b/app/Controllers/Chart/ChartController.php @@ -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; } diff --git a/app/Database/Migrations/2026-04-10-120000_AddRankedProgressChartType.php b/app/Database/Migrations/2026-04-10-120000_AddRankedProgressChartType.php new file mode 100644 index 0000000..0a24d85 --- /dev/null +++ b/app/Database/Migrations/2026-04-10-120000_AddRankedProgressChartType.php @@ -0,0 +1,27 @@ +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. + } +} diff --git a/app/Libraries/ChartRenderer.php b/app/Libraries/ChartRenderer.php index 5954fe6..e9eb786 100644 --- a/app/Libraries/ChartRenderer.php +++ b/app/Libraries/ChartRenderer.php @@ -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 $chart + * @param array> $rows + * @param array $display + * @return array + */ + 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 $display * @param array> $rows @@ -948,6 +1055,24 @@ class ChartRenderer return null; } + /** + * @param array|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 $display */ diff --git a/app/Models/ChartModel.php b/app/Models/ChartModel.php index d515687..25ec25c 100644 --- a/app/Models/ChartModel.php +++ b/app/Models/ChartModel.php @@ -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]', ]; diff --git a/app/Views/chart/builder.php b/app/Views/chart/builder.php index 332beeb..1cca33d 100644 --- a/app/Views/chart/builder.php +++ b/app/Views/chart/builder.php @@ -86,11 +86,28 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
Query variables
-
+
diff --git a/app/Views/chart/builder_scripts.php b/app/Views/chart/builder_scripts.php index e099b19..b3f079a 100644 --- a/app/Views/chart/builder_scripts.php +++ b/app/Views/chart/builder_scripts.php @@ -251,6 +251,42 @@ el.innerHTML = html || '

No KPI columns.

'; return; } + if (payload.engine === 'ranked_progress') { + const items = payload.items || []; + const footer = payload.footer || []; + let html = '
'; + if (payload.title) { + html += '
' + escapeHtml(payload.title) + '
'; + } + if (payload.subtitle) { + html += '
' + escapeHtml(payload.subtitle) + '
'; + } + items.forEach((it) => { + const pct = Math.max(0, Math.min(100, Number(it.bar_pct) || 0)); + const col = String(it.color || '#36a2eb'); + html += '
'; + html += ''; + html += '' + escapeHtml(it.label) + ''; + html += '
'; + html += '
'; + html += '
'; + html += '' + escapeHtml(it.value_label != null ? String(it.value_label) : String(it.value)) + ''; + html += '
'; + }); + if (footer.length) { + html += ''; + } + html += '
'; + 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.', diff --git a/app/Views/chart/index.php b/app/Views/chart/index.php index f5549b3..b1c3f25 100644 --- a/app/Views/chart/index.php +++ b/app/Views/chart/index.php @@ -17,44 +17,60 @@
getFlashdata('error')) ?>
- -
-
- bar_chart -

No charts yet. Create a query first, then build a chart from it.

- Create chart -
-
- -
- -
-
-
-
-
-
-
ยท
+
+
+ + + + + + + + + + + + + + + + + + + 'cb-badge-type--sql', + default => 'cb-badge-type--visual', + }; + ?> + + + + + + + + + +
NameData SourceChart typeUpdatedActions
No charts yet. Create a query first, then build a chart from it.
+
+
+
+
+ + Edit +
+ + +
+
+ + +
- - -
Updated
-
- - Edit -
- - -
-
- - -
-
- - - +
- +
endSection() ?> diff --git a/app/Views/dashboard/_view_scripts.php b/app/Views/dashboard/_view_scripts.php index cc2c000..09b4ad3 100644 --- a/app/Views/dashboard/_view_scripts.php +++ b/app/Views/dashboard/_view_scripts.php @@ -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 = '
' + escapeHtml(payload?.message || 'No data') + '
'; return; @@ -289,6 +290,43 @@ el.innerHTML = html || '

No KPI data.

'; return; } + if (payload.engine === 'ranked_progress') { + const items = payload.items || []; + const footer = payload.footer || []; + let html = '
'; + if (payload.title) { + html += '
' + escapeHtml(payload.title) + '
'; + } + if (payload.subtitle) { + html += '
' + escapeHtml(payload.subtitle) + '
'; + } + items.forEach((it) => { + const pct = Math.max(0, Math.min(100, Number(it.bar_pct) || 0)); + const col = String(it.color || '#36a2eb'); + html += '
'; + html += ''; + html += '' + escapeHtml(it.label) + ''; + html += '
'; + html += '
'; + html += '
'; + html += '' + escapeHtml(it.value_label != null ? String(it.value_label) : String(it.value)) + ''; + html += '
'; + }); + if (footer.length) { + html += ''; + } + html += '
'; + 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 = ''; + const wrapPill = (innerHtml, val, canClear) => { + const filled = String(val ?? '').trim() !== ''; + let h = '
'; + h += '' + escapeHtml(lab) + ''; + h += innerHtml; + if (canClear) { + h += ''; + } + h += '
'; + return h; + }; if (def.type === 'number') { - return '
' + labelHtml + '
'; + const inp = ''; + return wrapPill(inp, current, true); } if (def.type === 'date') { - return '
' + labelHtml + '
'; + const inp = ''; + 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 = '
' + labelHtml + ''; if (def.type === 'select') h += ''; opts.forEach((o) => { const v = typeof o === 'object' ? (o.value ?? o.label) : o; @@ -413,10 +464,11 @@ : (String(current) === vv ? ' selected' : ''); h += ''; }); - h += '
'; - return h; + h += ''; + return wrapPill(h, current, def.type !== 'multi_select'); } - return '
' + labelHtml + '
'; + const txt = ''; + 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'); diff --git a/public/assets/css/chart-builder.css b/public/assets/css/chart-builder.css index ef876a1..c98c0a3 100644 --- a/public/assets/css/chart-builder.css +++ b/public/assets/css/chart-builder.css @@ -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%; +} diff --git a/public/assets/css/dashboard.css b/public/assets/css/dashboard.css index 0db938c..7e2693b 100644 --- a/public/assets/css/dashboard.css +++ b/public/assets/css/dashboard.css @@ -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; } diff --git a/public/assets/css/variables.css b/public/assets/css/variables.css index 3b2199a..b674304 100644 --- a/public/assets/css/variables.css +++ b/public/assets/css/variables.css @@ -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);