feat(cpp/viz): static-plot primitive + tooltips + rotated labels + card compacta
Nuevo primitivo compartido: - cpp/functions/viz/plot_static.h: header-only con flags ImPlotFlags / ImPlotAxisFlags agrupados (NoFrame|NoMenus|NoBoxSelect|NoMouseText + Lock|NoInitialFit|NoHighlight) para visualizacion estatica en dashboards. Lo usan todos los charts de viz/. Charts refactorizados a v1.1 con parametro `height` explicito (rompe el feedback loop con contenedores AutoResizeY que producia vibracion al redimensionar) y ejes pineados con ImPlotCond_Always: - bar_chart v1.2: tooltip al hover (label + valor) + auto-rotacion de labels a 45 cuando no caben horizontalmente (medidos con CalcTextSize vs ancho del plot). Los labels rotados se dibujan manualmente con ImDrawList::PrimQuadUV + ImFontBaked::FindGlyph (API ImGui 1.92+). - pie_chart v1.1: tooltip por slice (detecta cual via atan2 desde centro en sentido CCW matematico, que es como ImPlot dibuja los slices desde angle0=90) con label + valor + porcentaje. Aspect 1:1 mantenido. - line_plot, scatter_plot, histogram v1.1: ejes pineados con limites calculados de min/max + 5% headroom (histogram usa AutoFit por los bins dinamicos, con Lock para bloquear pan/zoom). kpi_card v1.2: card mas compacta — altura 78px (antes 108), font scale 1.4x (antes 1.8x), padding sm (antes md). Apto para densidades altas de KPIs en dashboards. fullscreen_window v0.2: NoScrollbar|NoScrollWithMouse para eliminar el scrollbar fugaz que aparecia cuando el contenido excedia por 1-2px la ventana, reflow de ancho y vibracion visible al redimensionar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+132
-27
@@ -1,20 +1,86 @@
|
||||
#include "viz/bar_chart.h"
|
||||
#include "viz/plot_static.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
#include "implot.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
// 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.
|
||||
constexpr float kPI = 3.14159265358979323846f;
|
||||
|
||||
// Dibuja `text` rotado `angle` rad alrededor de `pivot` usando el font atlas
|
||||
// actual. Origen = primer glyph anclado en pivot; el texto avanza en la
|
||||
// direccion (cos(angle), sin(angle)) en coords de pantalla (Y abajo).
|
||||
void draw_text_rotated(ImDrawList* dl, ImFont* font, float font_size,
|
||||
ImVec2 pivot, float angle, ImU32 col, const char* text) {
|
||||
if (!text || !*text) return;
|
||||
// En ImGui >= 1.92 los glyphs viven en ImFontBaked, no en ImFont. Hay
|
||||
// que pedir el baked al tamano deseado y usar sus coords (ya en px).
|
||||
ImFontBaked* baked = font->GetFontBaked(font_size);
|
||||
if (!baked) return;
|
||||
|
||||
const float cos_a = std::cos(angle);
|
||||
const float sin_a = std::sin(angle);
|
||||
|
||||
dl->PushTexture(font->OwnerAtlas->TexRef);
|
||||
|
||||
float pen_x = 0.0f;
|
||||
for (const char* p = text; *p; ) {
|
||||
unsigned int c = 0;
|
||||
int len = ImTextCharFromUtf8(&c, p, nullptr);
|
||||
if (len <= 0) break;
|
||||
p += len;
|
||||
if (c < 0x20) continue;
|
||||
|
||||
ImFontGlyph* g = baked->FindGlyph(static_cast<ImWchar>(c));
|
||||
if (!g) continue;
|
||||
|
||||
if (g->Visible) {
|
||||
const float x0 = pen_x + g->X0;
|
||||
const float y0 = g->Y0;
|
||||
const float x1 = pen_x + g->X1;
|
||||
const float y1 = g->Y1;
|
||||
|
||||
auto rot = [&](float x, float y) -> ImVec2 {
|
||||
return ImVec2(pivot.x + x * cos_a - y * sin_a,
|
||||
pivot.y + x * sin_a + y * cos_a);
|
||||
};
|
||||
|
||||
const ImVec2 p0 = rot(x0, y0);
|
||||
const ImVec2 p1 = rot(x1, y0);
|
||||
const ImVec2 p2 = rot(x1, y1);
|
||||
const ImVec2 p3 = rot(x0, y1);
|
||||
|
||||
dl->PrimReserve(6, 4);
|
||||
dl->PrimQuadUV(p0, p1, p2, p3,
|
||||
ImVec2(g->U0, g->V0), ImVec2(g->U1, g->V0),
|
||||
ImVec2(g->U1, g->V1), ImVec2(g->U0, g->V1),
|
||||
col);
|
||||
}
|
||||
pen_x += g->AdvanceX;
|
||||
}
|
||||
|
||||
dl->PopTexture();
|
||||
}
|
||||
|
||||
// Mide el ancho total de los labels como si fueran horizontales + padding,
|
||||
// y decide si caben en `plot_width`. Si no caben, el chart los rotara 45
|
||||
// grados en vez de mostrarlos horizontales (ver draw_bars).
|
||||
bool labels_need_rotation(const char* const* labels, int count, float plot_width) {
|
||||
if (count <= 1 || plot_width <= 1.0f) return false;
|
||||
float total = 0.0f;
|
||||
constexpr float pad = 12.0f;
|
||||
for (int i = 0; i < count; i++) {
|
||||
total += ImGui::CalcTextSize(labels[i]).x + pad;
|
||||
}
|
||||
return total > plot_width;
|
||||
}
|
||||
|
||||
// Barras verticales con ejes pineados, altura explicita, tooltip al pasar el
|
||||
// mouse y labels rotados a 45 grados cuando no caben horizontalmente.
|
||||
template <typename T>
|
||||
void draw_bars(const char* title, const char* const* labels, const T* values,
|
||||
int count, double bar_width, float height) {
|
||||
@@ -25,34 +91,73 @@ void draw_bars(const char* title, const char* const* labels, const T* values,
|
||||
if (static_cast<double>(values[i]) > y_max) y_max = static_cast<double>(values[i]);
|
||||
}
|
||||
if (y_max <= 0.0) y_max = 1.0;
|
||||
y_max *= 1.15; // 15% headroom sobre la barra mas alta
|
||||
y_max *= 1.15;
|
||||
|
||||
const ImPlotFlags plot_flags =
|
||||
ImPlotFlags_NoMenus | ImPlotFlags_NoBoxSelect | ImPlotFlags_NoMouseText
|
||||
| ImPlotFlags_NoInputs | ImPlotFlags_NoFrame;
|
||||
const float hint_width = ImGui::GetContentRegionAvail().x;
|
||||
const bool rotate = labels_need_rotation(labels, count, hint_width);
|
||||
|
||||
const ImPlotAxisFlags x_flags =
|
||||
ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_Lock
|
||||
| ImPlotAxisFlags_NoInitialFit | ImPlotAxisFlags_NoGridLines
|
||||
| ImPlotAxisFlags_NoHighlight;
|
||||
// Reservamos ~48px abajo para los labels rotados (aprox 34px verticales
|
||||
// + padding). Sin rotacion, el plot ocupa toda la altura disponible.
|
||||
const float total_h = height > 0.0f ? height : 200.0f;
|
||||
const float label_reserve = rotate ? 48.0f : 0.0f;
|
||||
const ImVec2 plot_size(-1.0f, total_h - label_reserve);
|
||||
|
||||
const ImPlotAxisFlags y_flags =
|
||||
ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_Lock
|
||||
| ImPlotAxisFlags_NoInitialFit | ImPlotAxisFlags_NoHighlight;
|
||||
std::vector<double> positions(count);
|
||||
for (int i = 0; i < count; i++) positions[i] = i;
|
||||
|
||||
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;
|
||||
if (ImPlot::BeginPlot(title, plot_size, plot_static::kPlotFlags)) {
|
||||
const ImPlotAxisFlags x_flags = plot_static::kAxisFlags | ImPlotAxisFlags_NoGridLines;
|
||||
const ImPlotAxisFlags y_flags = plot_static::kAxisFlags;
|
||||
|
||||
ImPlot::SetupAxes(nullptr, nullptr, x_flags, y_flags);
|
||||
ImPlot::SetupAxisLimits(ImAxis_X1, -0.5, static_cast<double>(count) - 0.5,
|
||||
ImPlotCond_Always);
|
||||
ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, y_max, ImPlotCond_Always);
|
||||
ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, labels);
|
||||
|
||||
if (rotate) {
|
||||
// Tick positions con labels vacios: ImPlot dibuja ticks pero no
|
||||
// texto. Los labels los dibujamos a mano rotados mas abajo.
|
||||
std::vector<const char*> empty(count, "");
|
||||
ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, empty.data());
|
||||
} else {
|
||||
ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, labels);
|
||||
}
|
||||
|
||||
ImPlot::PlotBars("##data", values, count, bar_width);
|
||||
|
||||
// Tooltip
|
||||
if (ImPlot::IsPlotHovered()) {
|
||||
ImPlotPoint mp = ImPlot::GetPlotMousePos();
|
||||
int idx = static_cast<int>(std::round(mp.x));
|
||||
if (idx >= 0 && idx < count
|
||||
&& std::fabs(mp.x - idx) <= bar_width * 0.5) {
|
||||
ImGui::BeginTooltip();
|
||||
ImGui::TextUnformatted(labels[idx]);
|
||||
ImGui::Separator();
|
||||
ImGui::Text("%.0f", static_cast<double>(values[idx]));
|
||||
ImGui::EndTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
// Labels rotados: solo si decidimos rotar. Los dibujamos usando el
|
||||
// drawlist de la VENTANA (no el del plot) para que no se clipen al
|
||||
// rectangulo del plot y puedan extenderse en el margen inferior.
|
||||
if (rotate) {
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImFont* font = ImGui::GetFont();
|
||||
const float font_size = ImGui::GetFontSize();
|
||||
const ImU32 col = ImGui::GetColorU32(ImGuiCol_Text);
|
||||
const float angle = kPI * 0.25f; // 45 grados en pantalla (Y abajo)
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
ImVec2 px = ImPlot::PlotToPixels(ImPlotPoint(static_cast<double>(i), 0.0));
|
||||
// Desplazamos el pivote un pelin abajo del eje X para que el
|
||||
// label no pise los ticks.
|
||||
ImVec2 pivot(px.x, px.y + 6.0f);
|
||||
draw_text_rotated(dl, font, font_size, pivot, angle, col, labels[i]);
|
||||
}
|
||||
}
|
||||
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user