feat(cpp/viz): kpi_card v1.1 + bar_chart v1.1 — contenedor y altura fijas

kpi_card:
- v1.1: envuelve el contenido en BeginChild con surface bg + border +
  radius::md + padding::md (tokens). Replica Mantine Paper withBorder
  radius="md" p="md" usado en @fn_library/kpi_card.tsx.
- Ancho adaptativo via GetContentRegionAvail — requiere contenedor que
  propague ancho constrained (ImGui::BeginTable). dashboard_grid / BeginGroup
  no funcionan porque no constrainen ancho y la card desborda la celda.
- Linea de trend SIEMPRE visible: delta, sparkline, o em dash (text_dim)
  como placeholder, para que un grid de KPIs quede alineado vertical.
- Colores del delta via tokens (success/error) en vez de hardcoded ImVec4.

bar_chart:
- v1.1: altura explicita como parametro (default 200px). Sin esto, ImPlot
  con ImVec2(-1, 0) entra en feedback loop cuando esta dentro de un
  dashboard_panel (BeginChild con AutoResizeY): plot pide espacio -> padre
  se redimensiona -> plot recalcula. Efecto visual: las barras se deslizan
  los primeros frames.
- Ejes blindados: Lock + NoInitialFit + Cond_Always ademas de los flags
  previos. Y max pre-calculado con 15% de headroom.
- Sin inputs (NoInputs|NoFrame|NoBoxSelect|NoMouseText): estos charts son
  de resumen, no de exploracion.

Actualizados los .md correspondientes con el contrato visual + requisitos
de contenedor, para que cualquier dashboard que componga estos primitivos
obtenga el mismo look.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 20:59:51 +02:00
parent 4a95407d0e
commit 8e11c5cfce
5 changed files with 97 additions and 51 deletions
+29 -14
View File
@@ -5,12 +5,19 @@
namespace {
// Plot bars con ejes pineados (no se mueven entre frames) y labels categoricos.
// ImPlot por defecto auto-fitea Y en cada frame, lo que provoca oscilacion visual.
// Lo resolvemos calculando y_max una vez y forzandolo con ImPlotCond_Always.
// Render bars con ejes fijos y tamano de plot explicito. Dos cosas clave:
//
// 1) Limites pineados con ImPlotCond_Always + Lock: ImPlot por defecto
// auto-fitea Y cada frame, lo que provoca oscilacion visual.
//
// 2) Altura explicita (height > 0 -> ImVec2(-1, height)): sin esto, ImPlot
// toma el espacio disponible del contenedor. Si el contenedor es un
// BeginChild con AutoResizeY (como dashboard_panel), aparece un feedback
// loop: plot pide espacio al padre -> padre se redimensiona al plot ->
// plot recalcula -> las barras "se deslizan" en los primeros frames.
template <typename T>
void draw_bars(const char* title, const char* const* labels, const T* values,
int count, double bar_width) {
int count, double bar_width, float height) {
if (count <= 0) return;
double y_max = 0.0;
@@ -21,13 +28,21 @@ void draw_bars(const char* title, const char* const* labels, const T* values,
y_max *= 1.15; // 15% headroom sobre la barra mas alta
const ImPlotFlags plot_flags =
ImPlotFlags_NoMenus | ImPlotFlags_NoBoxSelect | ImPlotFlags_NoMouseText;
const ImPlotAxisFlags x_flags =
ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_Lock | ImPlotAxisFlags_NoGridLines;
const ImPlotAxisFlags y_flags =
ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_Lock;
ImPlotFlags_NoMenus | ImPlotFlags_NoBoxSelect | ImPlotFlags_NoMouseText
| ImPlotFlags_NoInputs | ImPlotFlags_NoFrame;
if (ImPlot::BeginPlot(title, ImVec2(-1, 0), plot_flags)) {
const ImPlotAxisFlags x_flags =
ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_Lock
| ImPlotAxisFlags_NoInitialFit | ImPlotAxisFlags_NoGridLines
| ImPlotAxisFlags_NoHighlight;
const ImPlotAxisFlags y_flags =
ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_Lock
| ImPlotAxisFlags_NoInitialFit | ImPlotAxisFlags_NoHighlight;
const ImVec2 plot_size(-1.0f, height > 0.0f ? height : 200.0f);
if (ImPlot::BeginPlot(title, plot_size, plot_flags)) {
std::vector<double> positions(count);
for (int i = 0; i < count; i++) positions[i] = i;
@@ -45,11 +60,11 @@ void draw_bars(const char* title, const char* const* labels, const T* values,
} // namespace
void bar_chart(const char* title, const char* const* labels, const float* values,
int count, float bar_width) {
draw_bars<float>(title, labels, values, count, static_cast<double>(bar_width));
int count, float bar_width, float height) {
draw_bars<float>(title, labels, values, count, static_cast<double>(bar_width), height);
}
void bar_chart(const char* title, const char* const* labels, const double* values,
int count, double bar_width) {
draw_bars<double>(title, labels, values, count, bar_width);
int count, double bar_width, float height) {
draw_bars<double>(title, labels, values, count, bar_width, height);
}
+7 -3
View File
@@ -1,6 +1,10 @@
#pragma once
// Renders a vertical bar chart using ImPlot.
// Renders a vertical bar chart using ImPlot with locked axes so it doesn't
// reflow between frames. Pass an explicit height so it doesn't enter a
// feedback loop with AutoResizeY containers.
// Call within an ImGui frame.
void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width = 0.67f);
void bar_chart(const char* title, const char* const* labels, const double* values, int count, double bar_width = 0.67);
void bar_chart(const char* title, const char* const* labels, const float* values,
int count, float bar_width = 0.67f, float height = 200.0f);
void bar_chart(const char* title, const char* const* labels, const double* values,
int count, double bar_width = 0.67, float height = 200.0f);
+24 -8
View File
@@ -3,11 +3,11 @@ name: bar_chart
kind: component
lang: cpp
domain: viz
version: "1.0.0"
version: "1.1.0"
purity: pure
signature: "void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width)"
description: "Renderiza un grafico de barras verticales usando ImPlot dentro de un frame ImGui"
tags: [implot, chart, visualization, gpu, bar]
signature: "void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width = 0.67f, float height = 200.0f)"
description: "Renderiza un grafico de barras verticales con ImPlot, ejes pineados (Lock + Cond_Always) y altura explicita para evitar feedback loops visuales"
tags: [implot, chart, visualization, gpu, bar, locked-axes]
uses_functions: []
uses_types: []
returns: []
@@ -21,7 +21,7 @@ file_path: "cpp/functions/viz/bar_chart.cpp"
framework: imgui
params:
- name: title
desc: "Titulo del grafico de barras"
desc: "Titulo del grafico de barras (tambien se usa como id interno del plot)"
- name: labels
desc: "Array de etiquetas para el eje X, una por barra"
- name: values
@@ -29,12 +29,28 @@ params:
- name: count
desc: "Numero de barras (longitud de labels y values)"
- name: bar_width
desc: "Ancho de cada barra como fraccion del espacio disponible (default 0.67)"
output: "Renderiza el grafico de barras en el frame ImGui actual"
desc: "Ancho de cada barra como fraccion del hueco de celda (default 0.67)"
- name: height
desc: "Altura del plot en pixeles (default 200). Explicita para evitar feedback loops con contenedores AutoResizeY"
output: "Renderiza el grafico de barras en el frame ImGui actual con ejes X/Y pineados"
---
# bar_chart
Wrapper atomico sobre `ImPlot::PlotBars` con configuracion automatica de etiquetas en el eje X.
Barras verticales con ImPlot, pensado para dashboards estaticos (resumenes, KPIs). Diseno:
- **Ejes pineados** — `ImPlotCond_Always` + `ImPlotAxisFlags_Lock` + `ImPlotAxisFlags_NoInitialFit`. Sin esto, ImPlot auto-fitea cada frame y las barras oscilan los primeros frames.
- **Y max con 15% de headroom** — calculado a partir de `values` una vez, mas estetico que el fit apretado de ImPlot.
- **Altura explicita** — `ImVec2(-1, height)` en vez de `ImVec2(-1, 0)`. Dentro de un `dashboard_panel` (BeginChild con AutoResizeY), un height=0 crea un feedback loop: el plot pide espacio al padre, el padre se redimensiona al plot, el plot recalcula y asi. El efecto visual es que las barras se deslizan de lado al abrir la ventana. Con height fija no hay loop.
- **Sin inputs** — `ImPlotFlags_NoInputs | NoFrame | NoBoxSelect | NoMouseText`. Pensado para visualizacion, no exploracion.
Debe llamarse dentro del render callback de `fn::run_app`.
## Ejemplo
```cpp
const char* langs[] = {"go", "py", "ts", "sh", "cpp"};
float counts[] = {412, 187, 94, 63, 36};
bar_chart("Functions by lang", langs, counts, 5); // height por defecto 200
bar_chart("Compact", langs, counts, 5, 0.8f, 120.0f); // compacto
```
+28 -18
View File
@@ -10,15 +10,16 @@ void kpi_card(const char* label, float value, float delta_percent,
using namespace fn_tokens;
// Card container — surface bg, border, rounded, padding.
// Mirrors Mantine <Paper withBorder shadow="xs" radius="md" p="md" /> used in
// @fn_library/kpi_card.tsx, adapted for ImGui dark theme.
// Mirrors Mantine <Paper withBorder shadow="xs" radius="md" p="md" /> usado
// en @fn_library/kpi_card.tsx, adaptado a ImGui dark theme via tokens.
ImGui::PushStyleColor(ImGuiCol_ChildBg, colors::surface);
ImGui::PushStyleColor(ImGuiCol_Border, colors::border);
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, radius::md);
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spacing::md, spacing::md));
// Unique child id per label so multiple cards in the same window don't collide.
// Unique child id por label para que multiples cards en la misma ventana
// no colisionen.
char child_id[96];
std::snprintf(child_id, sizeof(child_id), "##kpi_%s", label);
@@ -28,41 +29,50 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::BeginChild(child_id, ImVec2(width, 0),
ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY);
// Label — muted (Mantine "dimmed" text)
// Label — muted
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted);
ImGui::TextUnformatted(label);
ImGui::PopStyleColor();
// Value — scaled up (Mantine fw={700} fontSize=1.875rem)
// Value — scaled up. El format controla el sufijo (ej: "%.0f%%" para
// porcentajes, "%.0f" para enteros, "$%.2f" para moneda).
ImGui::SetWindowFontScale(1.8f);
char value_buf[64];
std::snprintf(value_buf, sizeof(value_buf), format, value);
ImGui::TextUnformatted(value_buf);
ImGui::SetWindowFontScale(1.0f);
// Delta badge — only render when meaningful
// Delta / trend — SIEMPRE se reserva la linea aunque no haya tendencia,
// para que todas las cards tengan la misma altura. Cuando no hay delta
// ni history, se muestra un guion en text_dim para mantener el ritmo
// visual sin hacer ruido con "+0.0%".
const bool has_delta = delta_percent != 0.0f;
const bool has_history = history != nullptr && history_count > 0;
if (has_delta || has_history) {
if (has_delta) {
const bool positive = delta_percent >= 0.0f;
const ImVec4 delta_color = positive ? colors::success : colors::error;
if (has_delta) {
char delta_buf[32];
if (positive) {
std::snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xb2 +%.1f%%", delta_percent);
} else {
std::snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xbc %.1f%%", delta_percent);
}
ImGui::PushStyleColor(ImGuiCol_Text, delta_color);
ImGui::TextUnformatted(delta_buf);
ImGui::PopStyleColor();
char delta_buf[32];
if (positive) {
std::snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xb2 +%.1f%%", delta_percent);
} else {
std::snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xbc %.1f%%", delta_percent);
}
ImGui::PushStyleColor(ImGuiCol_Text, delta_color);
ImGui::TextUnformatted(delta_buf);
ImGui::PopStyleColor();
if (has_history) {
sparkline(label, history, history_count, delta_color, 120.0f, 24.0f);
}
} else if (has_history) {
// Sin delta pero con historia: sparkline en primary (neutro).
sparkline(label, history, history_count, colors::primary, 120.0f, 24.0f);
} else {
// Placeholder para preservar altura de la card.
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim);
ImGui::TextUnformatted("\xe2\x80\x94"); // em dash
ImGui::PopStyleColor();
}
ImGui::EndChild();
+9 -8
View File
@@ -3,12 +3,12 @@ name: kpi_card
kind: component
lang: cpp
domain: viz
version: "1.0.0"
version: "1.1.0"
purity: pure
signature: "void kpi_card(const char* label, float value, float delta_percent, const float* history = nullptr, int history_count = 0, const char* format = \"%.1f\")"
description: "Card de KPI con valor grande, delta porcentual, y sparkline historico para dashboards"
tags: [imgui, kpi, card, dashboard, metrics, sparkline]
uses_functions: ["sparkline_cpp_viz"]
description: "Card de KPI con valor grande, delta porcentual y sparkline historico. Contenedor con surface bg, borde y radius via tokens (Mantine Paper equivalente)."
tags: [imgui, kpi, card, dashboard, metrics, sparkline, tokens]
uses_functions: ["sparkline_cpp_viz", "tokens_cpp_core"]
uses_types: []
returns: []
returns_optional: false
@@ -64,8 +64,9 @@ ImGui::Columns(1);
## Notas
- El ancho total del grupo es aproximadamente 150px, apto para grids de 2-4 columnas.
- **v1.1**: la card ahora se renderiza dentro de un `BeginChild` con `surface` bg, `border` y `radius::md` de `fn_tokens` — replica el `<Paper withBorder shadow="xs" radius="md" p="md">` de `@fn_library/kpi_card.tsx`.
- El ancho se adapta al contenedor padre via `GetContentRegionAvail().x`. Para que ocupe exactamente una celda usar `ImGui::BeginTable``BeginGroup` / `dashboard_grid` no propagan ancho constrained y la card desbordaria la celda.
- La altura es siempre la misma (label + value + linea de trend), aunque no haya delta ni history (se muestra un em dash en `text_dim` como placeholder) para que un grid de KPIs quede alineado.
- El escalado de fuente usa `SetWindowFontScale(1.8f)` — compatible con cualquier fuente cargada; no requiere fonts adicionales.
- Los caracteres UTF-8 del triangulo (`▲` U+25B2 y `▼` U+25BC) requieren que la fuente ImGui tenga el rango de simbolos geometricos cargado, o bien sustituir por ASCII (`^` y `v`).
- El color verde del delta es `ImVec4(0.20, 0.80, 0.35, 1.0)` y el rojo `ImVec4(0.90, 0.25, 0.25, 1.0)`, coherentes con los colores del sparkline subyacente.
- `BeginGroup`/`EndGroup` permite usar `SameLine()` despues de `kpi_card` y que el cursor avance correctamente.
- Los caracteres UTF-8 del triangulo (`▲` U+25B2 y `▼` U+25BC) y del em dash (`—` U+2014) requieren que la fuente ImGui tenga el rango de simbolos geometricos / puntuacion general cargado.
- Los colores del delta vienen de `fn_tokens::colors::{success, error}` y el placeholder del em dash usa `text_dim`.