chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)

Snapshot de WIP acumulado de sesiones previas antes de merge wave 1
del flow 0008 (kanban_cpp + agent_runner_api + DoD schema).

Incluye:
- dev/flows/0008-kanban-cpp-and-agent-workflows.md
- dev/issues/0112-0119*.md (7 sub-issues)
- WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:17:08 +02:00
parent ddb5366884
commit b9716a7cd6
119 changed files with 14929 additions and 3084 deletions
+40 -4
View File
@@ -381,10 +381,16 @@ if(EXISTS ${_PG_DIR}/CMakeLists.txt)
add_subdirectory(${_PG_DIR} ${CMAKE_BINARY_DIR}/apps/primitives_gallery)
endif()
# --- Tables playground (vive dentro de primitives_gallery/playground/tables/) ---
if(EXISTS ${_PG_DIR}/playground/tables/CMakeLists.txt)
add_subdirectory(${_PG_DIR}/playground/tables ${CMAKE_BINARY_DIR}/apps/primitives_gallery/playground/tables)
endif()
# --- Tables playground DEPRECATED (issue 0108) ---
# Sustituido por apps/tables_qa. El playground legacy queda solo como historia
# del split data_table 0107c. NO se builda mas — su self_test (430 checks
# contra logica legacy) ya esta cubierto por:
# - cpp/tests/ (Catch2 unit tests de la logica pura del registry)
# - apps/tables_qa/ (testbed del modulo data_table v2.0.0+)
# Para revivirlo (temporal, debugging): descomentar el bloque if(EXISTS ...).
# if(EXISTS ${_PG_DIR}/playground/tables/CMakeLists.txt)
# add_subdirectory(${_PG_DIR}/playground/tables ${CMAKE_BINARY_DIR}/apps/primitives_gallery/playground/tables)
# endif()
# --- text_editor + file_watcher smoke test (lives in apps/) ---
if(NOT DEFINED _TES_DIR)
@@ -493,3 +499,33 @@ set(_APP_HUB_LAUNCHER_DIR ${CMAKE_SOURCE_DIR}/../apps/app_hub_launcher)
if(EXISTS ${_APP_HUB_LAUNCHER_DIR}/CMakeLists.txt)
add_subdirectory(${_APP_HUB_LAUNCHER_DIR} ${CMAKE_BINARY_DIR}/apps/app_hub_launcher)
endif()
# --- services_monitor (lives in apps/, issue 0096) ---
set(_SERVICES_MONITOR_DIR ${CMAKE_SOURCE_DIR}/../apps/services_monitor)
if(EXISTS ${_SERVICES_MONITOR_DIR}/CMakeLists.txt)
add_subdirectory(${_SERVICES_MONITOR_DIR} ${CMAKE_BINARY_DIR}/apps/services_monitor)
endif()
# --- app_gestion (lives in apps/, issue 0096) ---
set(_APP_GESTION_DIR ${CMAKE_SOURCE_DIR}/../apps/app_gestion)
if(EXISTS ${_APP_GESTION_DIR}/CMakeLists.txt)
add_subdirectory(${_APP_GESTION_DIR} ${CMAKE_BINARY_DIR}/apps/app_gestion)
endif()
# --- skill_tree (lives in apps/, issue 0096) ---
set(_SKILL_TREE_DIR ${CMAKE_SOURCE_DIR}/../apps/skill_tree)
if(EXISTS ${_SKILL_TREE_DIR}/CMakeLists.txt)
add_subdirectory(${_SKILL_TREE_DIR} ${CMAKE_BINARY_DIR}/apps/skill_tree)
endif()
# --- tables_qa (lives in apps/, issue 0096) ---
set(_TABLES_QA_DIR ${CMAKE_SOURCE_DIR}/../apps/tables_qa)
if(EXISTS ${_TABLES_QA_DIR}/CMakeLists.txt)
add_subdirectory(${_TABLES_QA_DIR} ${CMAKE_BINARY_DIR}/apps/tables_qa)
endif()
# --- process_explorer (lives in apps/, issue 0096) ---
set(_PROCESS_EXPLORER_DIR ${CMAKE_SOURCE_DIR}/../apps/process_explorer)
if(EXISTS ${_PROCESS_EXPLORER_DIR}/CMakeLists.txt)
add_subdirectory(${_PROCESS_EXPLORER_DIR} ${CMAKE_BINARY_DIR}/apps/process_explorer)
endif()
+293
View File
@@ -2,6 +2,7 @@
#include "version_generated.h"
#include "imgui.h"
#include "imgui_internal.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include "implot.h"
@@ -16,9 +17,11 @@
#include "core/log_window.h"
#include "core/layout_storage.h"
#include "gfx/gl_loader.h"
#include "app_modules.h"
#include <GLFW/glfw3.h>
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <filesystem>
@@ -26,6 +29,7 @@
#include <sys/stat.h>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
@@ -224,6 +228,43 @@ static void prune_dead_icon_attached() {
}
}
// Pinta la title bar (caption + bordes) en oscuro via DWM, para que el
// header del SO no se quede blanco mientras el cliente es dark. DWM la pinta
// el OS, no GLFW/ImGui — sin esta llamada queda en blanco aunque el resto
// este oscuro.
//
// Carga dwmapi.dll dinamicamente: evita anadir la dep al toolchain. Si la
// DLL/atributo no existe (Win10 < 1809), no hace nada — silencioso.
// Attr 20 = DWMWA_USE_IMMERSIVE_DARK_MODE (Win11 / Win10 >= build 18985).
// Attr 19 = nombre antiguo (Win10 1809..18984). Probar 20 primero, fallback 19.
// Force repaint con SWP_FRAMECHANGED — la ventana ya fue mostrada por GLFW
// antes de que lleguemos aqui.
typedef HRESULT (WINAPI *PFN_DwmSetWindowAttribute)(HWND, DWORD, LPCVOID, DWORD);
static std::unordered_set<HWND> g_dark_titlebar_applied;
static void attach_dark_titlebar_to_hwnd(HWND hwnd, bool dark) {
if (!hwnd) return;
if (g_dark_titlebar_applied.count(hwnd)) return; // idempotent
static HMODULE h_dwmapi = LoadLibraryW(L"dwmapi.dll");
if (!h_dwmapi) return;
static auto p_set = (PFN_DwmSetWindowAttribute)GetProcAddress(h_dwmapi, "DwmSetWindowAttribute");
if (!p_set) return;
BOOL value = dark ? TRUE : FALSE;
HRESULT hr = p_set(hwnd, 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */, &value, sizeof(value));
if (FAILED(hr)) {
p_set(hwnd, 19 /* legacy name on Win10 1809..18984 */, &value, sizeof(value));
}
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
g_dark_titlebar_applied.insert(hwnd);
}
static void prune_dead_dark_titlebar() {
for (auto it = g_dark_titlebar_applied.begin(); it != g_dark_titlebar_applied.end();) {
if (!IsWindow(*it)) it = g_dark_titlebar_applied.erase(it);
else ++it;
}
}
static void install_sizemove_subclass(GLFWwindow* w) {
if (!w) return;
install_sizemove_subclass_hwnd(glfwGetWin32Window(w));
@@ -391,6 +432,221 @@ const char* framework_description() {
return FN_MODULE_FRAMEWORK_DESCRIPTION;
}
// ----------------------------------------------------------------------------
// Header badge overlay — identidad por app en viewports secundarios.
// ----------------------------------------------------------------------------
// Cuando una app C++ tiene N panels y el usuario arrastra varios fuera del
// main window, cada panel se convierte en su propio OS viewport. Sin marcas
// visuales adicionales, si tienes 3 apps abiertas a la vez no sabes de cual
// viene cada panel flotante. Este overlay dibuja un cuadrado redondeado de
// ~18px con la inicial de la app en la esquina top-left de la title bar de
// cada viewport secundario. Solo en secundarios — el main ya tiene icono
// del SO en titlebar/taskbar (attach_app_icon_to_hwnd).
//
// Filosofia:
// - Defaults producen identidad util sin tocar la app (color hash-derivado
// desde about.name, glyph = primera letra).
// - Apps con icon.accent en su app.md pueden pasar el mismo hex para
// coherencia con App Hub.
// - ForegroundDrawList: dibuja por encima del titlebar de ImGui sin
// necesidad de envolver Begin() ni hookear el render del titulo.
// ----------------------------------------------------------------------------
// Parsea "#RRGGBB" o "RRGGBB" a ImU32 ABGR. Devuelve 0 si invalido.
static ImU32 parse_hex_color_abgr(const char* s) {
if (!s || !*s) return 0;
if (*s == '#') ++s;
auto h = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
int v[6];
for (int i = 0; i < 6; ++i) {
v[i] = h(s[i]);
if (v[i] < 0) return 0;
}
int r = (v[0] << 4) | v[1];
int g = (v[2] << 4) | v[3];
int b = (v[4] << 4) | v[5];
return IM_COL32(r, g, b, 255);
}
// Hash-derivado: FNV-1a 32-bit -> H en [0,360), S=0.58, V=0.78 -> ImU32 ABGR.
// Estable por nombre, distribuye razonablemente entre N apps.
static ImU32 derive_color_from_name(const char* name) {
if (!name || !*name) name = "fn_registry";
unsigned h = 2166136261u;
for (const char* p = name; *p; ++p) {
h ^= (unsigned char)*p;
h *= 16777619u;
}
float hue = (float)(h % 360u);
float s = 0.58f, v = 0.78f;
float c = v * s;
float hp = hue / 60.0f;
float x = c * (1.0f - std::fabs(std::fmod(hp, 2.0f) - 1.0f));
float r=0, g=0, b=0;
if (hp < 1) { r=c; g=x; }
else if (hp < 2) { r=x; g=c; }
else if (hp < 3) { g=c; b=x; }
else if (hp < 4) { g=x; b=c; }
else if (hp < 5) { r=x; b=c; }
else { r=c; b=x; }
float m = v - c;
int R = (int)((r + m) * 255.0f + 0.5f);
int G = (int)((g + m) * 255.0f + 0.5f);
int B = (int)((b + m) * 255.0f + 0.5f);
return IM_COL32(R, G, B, 255);
}
// Decide string a renderizar como glyph. Devuelve puntero a buffer estatico
// thread-local cuando hace falta normalizar la primera letra del nombre.
static const char* resolve_badge_glyph(const AppConfig& cfg) {
const char* g = cfg.header_badge.glyph;
if (g && *g) return g;
static thread_local char letter[8] = {0};
const char* nm = (cfg.about.name && *cfg.about.name) ? cfg.about.name : cfg.title;
char first = (nm && *nm) ? nm[0] : '?';
if (first >= 'a' && first <= 'z') first = (char)(first - 'a' + 'A');
letter[0] = first;
letter[1] = '\0';
return letter;
}
// Color final con precedencia:
// 1) Override explicito en cfg.header_badge.accent_hex (main.cpp)
// 2) Codegen extern fn::app_header_accent_hex (icon.accent del app.md)
// 3) Hash-derived desde about.name (siempre estable, da identidad gratis)
static ImU32 resolve_badge_color(const AppConfig& cfg) {
ImU32 c = parse_hex_color_abgr(cfg.header_badge.accent_hex);
if (c != 0) return c;
c = parse_hex_color_abgr(app_header_accent_hex);
if (c != 0) return c;
const char* nm = (cfg.about.name && *cfg.about.name) ? cfg.about.name : cfg.title;
return derive_color_from_name(nm);
}
// Textura GL del icono de la app — extraida del HICON embebido en el .exe
// (resource ID 101 generado por add_imgui_app desde appicon.ico). Cargada
// perezosamente al primer frame y reutilizada en cada draw. 0 = no disponible.
static GLuint g_app_icon_texture = 0;
static int g_app_icon_size = 0;
#ifdef _WIN32
// Carga el icono embebido a 32x32 y sube como textura GL RGBA8. Linear
// filtering para que escalar 32->18 sea suave. Devuelve 0 si falla.
static GLuint upload_hicon_to_gl_texture() {
const int sz = 32;
HICON hicon = (HICON)LoadImageW(GetModuleHandleW(nullptr),
MAKEINTRESOURCEW(FN_APP_ICON_RES_ID),
IMAGE_ICON, sz, sz,
LR_DEFAULTCOLOR);
if (!hicon) return 0;
ICONINFO ii{};
if (!GetIconInfo(hicon, &ii)) { DestroyIcon(hicon); return 0; }
HDC hdc = CreateCompatibleDC(nullptr);
BITMAPINFO bmi{};
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = sz;
bmi.bmiHeader.biHeight = -sz; // top-down
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32;
bmi.bmiHeader.biCompression = BI_RGB;
std::vector<unsigned char> pixels(sz * sz * 4, 0);
int ok = GetDIBits(hdc, ii.hbmColor, 0, sz, pixels.data(), &bmi, DIB_RGB_COLORS);
DeleteDC(hdc);
if (ii.hbmColor) DeleteObject(ii.hbmColor);
if (ii.hbmMask) DeleteObject(ii.hbmMask);
DestroyIcon(hicon);
if (ok == 0) return 0;
// BGRA -> RGBA (Windows DIB es BGRA).
for (int i = 0; i < sz * sz; ++i) {
unsigned char b = pixels[i*4 + 0];
unsigned char r = pixels[i*4 + 2];
pixels[i*4 + 0] = r;
pixels[i*4 + 2] = b;
}
GLuint tex = 0;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, sz, sz, 0, GL_RGBA, GL_UNSIGNED_BYTE,
pixels.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
return tex;
}
#endif
// Itera todas las ventanas ImGui y pinta el badge en la title bar de las que
// viven en un viewport secundario (panel arrastrado fuera del main window).
// Usa imgui_internal.h para acceder a g.Windows y a TitleBarRect(), y dibuja
// directamente en la DrawList propia de cada ventana — asi el badge va en
// el mismo paso de render que el resto del panel, sin depender de
// ForegroundDrawList del viewport (que no se renderiza en algunas combos de
// backend + multi-viewport).
//
// Si tenemos icono GL cargado (Windows con appicon.ico embebido), se dibuja
// el icono real (mismo bitmap que el taskbar). Sin icono, fallback a
// cuadrado redondeado del color accent con la inicial blanca del app name.
//
// Filtro de ventanas:
// - Activa, no Hidden, no Collapsed.
// - No es child window.
// - No es popup/tooltip/menu (NoTitleBar => skip).
// - No esta dockeada en un nodo (DockIsActive => su titlebar es tabbar del
// host; el host window aparece en g.Windows por separado y SI recibe badge).
// - Su viewport != main viewport.
static void draw_header_badge_on_floating_panels(const AppConfig& cfg) {
if (!cfg.header_badge.enabled) return;
ImGuiContext& g = *ImGui::GetCurrentContext();
ImGuiViewport* main_vp = ImGui::GetMainViewport();
const ImU32 bg = resolve_badge_color(cfg);
const char* glyph = resolve_badge_glyph(cfg);
const float sz = cfg.header_badge.size_px > 4.0f ? cfg.header_badge.size_px : 18.0f;
const float mg = cfg.header_badge.margin_px >= 0.0f ? cfg.header_badge.margin_px : 6.0f;
const float round = sz * 0.22f;
const bool has_texture = (g_app_icon_texture != 0);
for (int i = 0; i < g.Windows.Size; ++i) {
ImGuiWindow* w = g.Windows[i];
if (!w || !w->WasActive || w->Hidden) continue;
if (w->Flags & ImGuiWindowFlags_ChildWindow) continue;
if (w->Flags & ImGuiWindowFlags_NoTitleBar) continue;
if (w->DockIsActive) continue; // titlebar reemplazado por tab bar del host
if (w->Viewport == nullptr || w->Viewport == main_vp) continue;
if (w->Collapsed) continue;
ImRect tb = w->TitleBarRect();
ImVec2 p0(tb.Min.x + mg, tb.Min.y + (tb.GetHeight() - sz) * 0.5f);
ImVec2 p1(p0.x + sz, p0.y + sz);
ImDrawList* dl = w->DrawList;
if (has_texture) {
dl->AddImageRounded((ImTextureID)(intptr_t)g_app_icon_texture,
p0, p1, ImVec2(0,0), ImVec2(1,1),
IM_COL32_WHITE, round);
} else {
dl->AddRectFilled(p0, p1, bg, round);
ImVec2 ts = ImGui::CalcTextSize(glyph);
ImVec2 tp(p0.x + (sz - ts.x) * 0.5f,
p0.y + (sz - ts.y) * 0.5f);
dl->AddText(tp, IM_COL32(255, 255, 255, 255), glyph);
}
}
}
int run_app(AppConfig config, std::function<void()> render_fn) {
// Logger primero para capturar fallos del propio init (GLFW, ventana, GL).
if (config.log.file_path != nullptr) {
@@ -460,6 +716,14 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
// barra de tareas, Alt+Tab y title bar (GLFW no propaga el icono de
// recursos del .exe a su WNDCLASS por defecto).
attach_app_icon_to_hwnd(glfwGetWin32Window(window));
// Title bar oscuro (DWM) si el tema lo es. Sin esto el header del SO
// queda blanco aunque el cliente sea dark.
{
const bool dark = (config.theme == ThemeMode::FnDark ||
config.theme == ThemeMode::ImGuiDark);
attach_dark_titlebar_to_hwnd(glfwGetWin32Window(window), dark);
}
#endif
// Carga punteros a funciones GL >= 2.0 si la app lo pide. En Linux es
@@ -484,6 +748,14 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
// Multi-viewport docking: payload viewport y target viewport swappean
// buffers independientes, asi que los dock preview overlays (los rects
// azul/gris que indican zonas droppeables) parecen vibrar 1px contra el
// payload arrastrado. Con TransparentPayload el payload se vuelve
// invisible al arrastrar y los rects solo se pintan en el target ->
// ningun desync visible. Recomendado por upstream cuando "rendering of
// multiple viewport cannot be synced".
io.ConfigDockingTransparentPayload = true;
// Title-bar-only move for ImGui windows. Critical for secondary viewports
// (floating panels) whose entire OS window is a single borderless ImGui
// window: without this flag, ImGui moves the window when the user drags
@@ -625,6 +897,9 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
prune_dead_subclassed();
prune_dead_icon_attached();
prune_dead_dark_titlebar();
const bool dark_tb = (config.theme == ThemeMode::FnDark ||
config.theme == ThemeMode::ImGuiDark);
ImGuiPlatformIO& pio_sub = ImGui::GetPlatformIO();
for (int i = 0; i < pio_sub.Viewports.Size; ++i) {
ImGuiViewport* vp = pio_sub.Viewports[i];
@@ -636,6 +911,9 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
// SetClassLongPtrW. WM_SETICON per-HWND es la unica forma de
// que el taskbar/titlebar muestren el icono.
attach_app_icon_to_hwnd(glfwGetWin32Window(gw));
// Misma logica para el title bar oscuro — cada viewport
// secundario tiene su propio HWND con caption pintado por DWM.
attach_dark_titlebar_to_hwnd(glfwGetWin32Window(gw), dark_tb);
}
}
#endif
@@ -750,6 +1028,17 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
fps_overlay();
}
// Identidad por app en viewports secundarios — badge en el title bar
// de cada panel arrastrado fuera del main window. Si Windows + tiene
// appicon.ico embebido, dibuja el mismo icono que el taskbar (PNG
// RGBA escalado). Si no, fallback a cuadrado accent + inicial.
#ifdef _WIN32
if (g_app_icon_texture == 0) {
g_app_icon_texture = upload_hicon_to_gl_texture();
}
#endif
draw_header_badge_on_floating_panels(config);
ImGui::Render();
int display_w, display_h;
glfwGetFramebufferSize(window, &display_w, &display_h);
@@ -800,6 +1089,10 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
}
// Cleanup
if (g_app_icon_texture != 0) {
glDeleteTextures(1, &g_app_icon_texture);
g_app_icon_texture = 0;
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImPlot3D::DestroyContext();
+36
View File
@@ -171,6 +171,42 @@ struct AppConfig {
// render_fn) las ventanas docked aparecen flotantes hasta el siguiente
// ciclo. Default null = no-op.
std::function<void()> pre_frame{};
// ------------------------------------------------------------------------
// Header badge — identidad visual en viewports secundarios (panels
// arrastrados fuera del main window). Cuando un panel se separa, el
// framework dibuja un pequeño cuadrado redondeado con la inicial de la
// app (o un glyph custom) en la esquina top-left de la title bar de su
// viewport, asi distingues de un vistazo de que app viene cada panel
// flotante cuando tienes varias apps abiertas a la vez.
//
// Si todos los campos quedan por defecto, el framework auto-deriva color
// estable desde about.name (hash -> HSV) y glyph desde la primera letra.
// No requiere accion en la app; con solo declarar about.name ya hay
// identidad. Apps que ya tengan icon.accent en su app.md deberian setear
// header_badge.accent_hex con el mismo hex para coherencia visual con el
// App Hub.
// ------------------------------------------------------------------------
struct AppHeaderBadge {
// Color de fondo del badge en formato "#RRGGBB" o "RRGGBB" sRGB.
// "" -> auto-derive desde about.name (hash estable).
const char* accent_hex = "";
// Glyph dibujado en blanco encima del fondo. nullptr/"" -> primera
// letra de about.name (uppercase). Soporta cualquier UTF-8 corto
// (1-2 chars o un TI_* macro de cpp/functions/core/icons_tabler.h).
const char* glyph = nullptr;
// Tamaño cuadrado del badge en pixels.
float size_px = 18.0f;
// Margen desde top-left del viewport.
float margin_px = 6.0f;
// false -> deshabilita el badge para esta app (capture mode, headless).
bool enabled = true;
};
AppHeaderBadge header_badge{};
};
// Run an ImGui application. The render_fn is called every frame
+15
View File
@@ -29,4 +29,19 @@ struct ModuleInfo {
extern const ModuleInfo app_modules_array[];
extern const unsigned long app_modules_count;
// App identity para el header badge en viewports secundarios (panels arrastrados
// fuera del main window). Auto-generados desde el bloque `icon:` del app.md
// por codegen_app_modules.py. Permiten que el framework dibuje el cuadrado
// accent con la identidad visual de la app sin que main.cpp deba pasar el hex
// manualmente.
//
// app_header_accent_hex: "#RRGGBB" desde icon.accent (default "" si no se
// declara — framework cae a hash-derived).
// app_header_glyph_name: nombre del glyph Phosphor desde icon.phosphor.
// Hoy informativo: el framework C++ no tiene fuente
// Phosphor cargada, asi que cae a primera letra de
// about.name. Reservado para futuro mapping Tabler.
extern const char* const app_header_accent_hex;
extern const char* const app_header_glyph_name;
} // namespace fn
+3 -9
View File
@@ -60,16 +60,10 @@ void about_window_render() {
ImGui::TextWrapped("%s", g_description.c_str());
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// --- Framework version (issue 0097) ---
ImGui::Text("Framework");
ImGui::SameLine();
ImGui::TextDisabled("v%s", fn::framework_version());
// --- Modules consumidos por la app (issue 0097) ---
// codegen_app_modules.py auto-prepende `framework_cpp` a uses_modules de
// toda app C++, asi que la tabla Modules SIEMPRE lista al framework con
// su version + cada modulo declarado en app.md::uses_modules.
if (fn::app_modules_count > 0) {
ImGui::Spacing();
ImGui::Separator();
@@ -2,6 +2,7 @@
// Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado.
#include "core/compute_column_stats.h"
#include "core/auto_detect_type.h" // parse_number
#include <algorithm>
#include <cmath>
+4 -1
View File
@@ -2,7 +2,10 @@
// Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado.
#pragma once
#include "core/auto_detect_type.h" // parse_number reutilizado en la impl
// NOTE: auto_detect_type.h (parse_number) is required by the .cpp impl only.
// It is NOT included here because data_table_types.h includes this header,
// and auto_detect_type.h itself references ColumnType (defined in
// data_table_types.h) — that would create a circular include.
#include <string>
#include <utility>
+210
View File
@@ -0,0 +1,210 @@
#include "core/compute_ring_layout.h"
#include <algorithm>
#include <cmath>
#include <functional> // std::hash
#include <unordered_map>
namespace fn_ring {
// ---------------------------------------------------------------------------
// Constantes internas
// ---------------------------------------------------------------------------
static constexpr float kPi = 3.14159265358979323846f;
static constexpr float kTwoPi = 2.0f * kPi;
// Radio minimo para ring 0 cuando ring_radii[0]==0 (evita colocar nodos en el
// origen exacto, reservado para HUD).
static constexpr float kRing0InnerMin = 30.0f;
// Umbral: si el bin tiene mas nodos de los que caben radialmente con separacion
// minima MIN_RADIAL_SPACING, activamos jitter angular.
static constexpr float kMinRadialSpacing = 18.0f;
// Amplitud maxima del jitter angular: ±0.4 del half-sector (se mantiene dentro
// del sector para no solapar el vecino).
static constexpr float kJitterMaxFraction = 0.4f;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Hash deterministico FNV-1a 32 bits (estable cross-platform, sin depender de
/// std::hash<string> que puede variar entre ABI/compiladores).
static uint32_t fnv1a_32(const std::string& s) {
uint32_t h = 2166136261u;
for (unsigned char c : s) {
h ^= c;
h *= 16777619u;
}
return h;
}
/// Devuelve valor deterministico en [-1.0, +1.0] usando el hash del id.
static float id_jitter(const std::string& id) {
uint32_t h = fnv1a_32(id);
// Normalizar a [-1, +1]
float v = static_cast<float>(h) / static_cast<float>(0xFFFFFFFFu); // [0,1]
return v * 2.0f - 1.0f; // [-1,+1]
}
/// Devuelve el mapa canonico de status → ring.
static StatusRingMap default_status_map() {
return {
{"completado", 0},
{"completed", 0},
{"in-progress", 1},
{"pendiente_unlocked", 2},
{"unlocked", 2},
{"pending", 2},
{"pendiente", 3},
{"locked", 3},
{"deferred", 4},
{"bloqueado", 4},
};
}
/// Busca status en el mapa. Retorna -1 si no encontrado.
static int lookup_ring(const std::string& status, const StatusRingMap& smap) {
for (auto& [k, v] : smap) {
if (k == status) return v;
}
return -1;
}
/// Busca domain en el orden. Retorna n_sectors-1 si no encontrado.
static int lookup_sector(const std::string& domain,
const DomainOrder& order,
int n_sectors) {
for (int i = 0; i < static_cast<int>(order.size()); ++i) {
if (order[i] == domain) return i;
}
return n_sectors - 1;
}
// ---------------------------------------------------------------------------
// Implementacion principal
// ---------------------------------------------------------------------------
std::vector<LayoutOutput>
compute_ring_layout(const std::vector<LayoutInput>& nodes,
const LayoutConfig& cfg,
const StatusRingMap& status_map_in,
const DomainOrder& domain_order) {
if (nodes.empty()) return {};
const StatusRingMap& smap = status_map_in.empty()
? default_status_map()
: status_map_in;
const int n_rings = static_cast<int>(cfg.ring_radii.size()) - 1;
const int n_sectors = cfg.n_sectors > 0 ? cfg.n_sectors : 18;
const float sector_angle = kTwoPi / static_cast<float>(n_sectors);
const float half_sector = sector_angle * 0.5f;
// Preasignar outputs con ring=-1 (descartados por defecto)
std::vector<LayoutOutput> out(nodes.size());
for (size_t i = 0; i < nodes.size(); ++i) {
out[i].id = nodes[i].id;
}
// --- Paso 1: mapear cada nodo a (ring, sector) ---
// indices_in_bin[ring][sector] = lista de indices en nodes[]
using BinKey = std::pair<int,int>;
struct BinKeyHash {
size_t operator()(const BinKey& k) const noexcept {
return std::hash<int>()(k.first) ^ (std::hash<int>()(k.second) << 16);
}
};
std::unordered_map<BinKey, std::vector<size_t>, BinKeyHash> bins;
for (size_t i = 0; i < nodes.size(); ++i) {
int ring = lookup_ring(nodes[i].status, smap);
if (ring < 0 || ring >= n_rings) {
// Nodo descartado: ring=-1, x/y=0 (ya inicializado)
continue;
}
int sector = lookup_sector(nodes[i].domain, domain_order, n_sectors);
out[i].ring = ring;
out[i].sector = sector;
bins[{ring, sector}].push_back(i);
}
// --- Paso 2: posicionar nodos dentro de cada bin ---
for (auto& [key, indices] : bins) {
const int ring = key.first;
const int sector = key.second;
// Ordenar bin: recency desc, id asc (deterministico)
std::sort(indices.begin(), indices.end(),
[&](size_t a, size_t b) {
float ra = nodes[a].recency;
float rb = nodes[b].recency;
if (ra != rb) return ra > rb;
return nodes[a].id < nodes[b].id;
});
// Asignar rank_in_bin
for (int rank = 0; rank < static_cast<int>(indices.size()); ++rank) {
out[indices[rank]].rank_in_bin = rank;
}
// Radio interior y exterior del ring
float r_inner = cfg.ring_radii[ring];
float r_outer = cfg.ring_radii[ring + 1];
// Caso especial: ring 0 con radio interno == 0
if (r_inner == 0.0f) r_inner = kRing0InnerMin;
// Aplicar padding
float r_lo = r_inner + cfg.bin_padding;
float r_hi = r_outer - cfg.bin_padding;
if (r_lo > r_hi) {
// Bin demasiado estrecho: usar el punto medio
r_lo = r_hi = (r_inner + r_outer) * 0.5f;
}
const int N = static_cast<int>(indices.size());
// Angulo central del sector (sin jitter)
float theta_center = cfg.start_angle
+ (static_cast<float>(sector) + 0.5f) * sector_angle;
// Determinar si activamos jitter angular (bin sobrecargado radialmente)
float band_height = r_hi - r_lo;
int radial_capacity = (band_height < 1.0f)
? 1
: std::max(1, static_cast<int>(band_height / kMinRadialSpacing));
bool use_jitter = (N > radial_capacity);
// Posicionar cada nodo
for (int rank = 0; rank < N; ++rank) {
size_t idx = indices[rank];
// Radio: distribucion uniforme en la banda
float r;
if (N == 1) {
r = (r_lo + r_hi) * 0.5f;
} else {
r = r_lo + (static_cast<float>(rank) + 0.5f) * (r_hi - r_lo)
/ static_cast<float>(N);
}
// Sub-jitter angular deterministico si bin sobrecargado
float jitter = 0.0f;
if (use_jitter) {
float raw = id_jitter(nodes[idx].id); // en [-1,+1]
jitter = raw * half_sector * kJitterMaxFraction;
}
float theta = theta_center + jitter;
out[idx].x = cfg.center_x + r * std::cos(theta);
out[idx].y = cfg.center_y + r * std::sin(theta);
}
}
return out;
}
} // namespace fn_ring
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <string>
#include <vector>
/// fn_ring — geometria pura para layout en anillos concentricos + sectores radiales.
/// Pure: sin I/O, sin estado global, sin RNG. Misma entrada → mismo output siempre.
/// C++17 STL only.
namespace fn_ring {
struct LayoutInput {
std::string id; // identidad unica para mapeo deterministico
std::string status; // categoria libre — mapeada al ring via status_map
std::string domain; // categoria libre — mapeada al sector via domain_order
float recency = 0.0f; // 0..1, ordena DENTRO de un (ring,sector) bin (desc)
};
struct LayoutOutput {
std::string id;
float x = 0.0f;
float y = 0.0f;
int ring = -1; // -1 = nodo descartado (status no mapeado)
int sector = 0;
int rank_in_bin = 0; // 0..N-1 dentro del bin (ring,sector)
};
struct LayoutConfig {
int n_sectors = 18;
float center_x = 0.0f;
float center_y = 0.0f;
// ring_radii[i] = radio interno del ring i
// ring_radii[i+1] = radio externo del ring i
// Default: 5 rings (0..4)
std::vector<float> ring_radii { 0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f };
float bin_padding = 14.0f; // padding interior del bin en pixels
float start_angle = 0.0f; // rotacion global del sector 0 en radianes
};
/// Mapea strings de status a indice de ring.
/// Status no listado → ring -1 (nodo descartado).
///
/// Default canonico (usado cuando se pasa vector vacio):
/// {"completado",0}, {"completed",0},
/// {"in-progress",1},
/// {"pendiente_unlocked",2}, {"unlocked",2}, {"pending",2},
/// {"pendiente",3}, {"locked",3},
/// {"deferred",4}, {"bloqueado",4}
using StatusRingMap = std::vector<std::pair<std::string, int>>;
/// Mapea strings de domain a indice de sector [0..n_sectors-1].
/// Domains no listados → sector n_sectors-1 (sector "otros").
using DomainOrder = std::vector<std::string>;
/// Compute ring+sector layout deterministico.
///
/// Para cada nodo:
/// 1. ring = status_map[node.status]; descartar si -1.
/// 2. sector = index(domain_order, node.domain); fallback n_sectors-1.
/// 3. Bin (ring, sector): ordenar por (recency desc, id asc), distribuir
/// uniformemente en la banda radial con padding. Sub-jitter angular
/// deterministico via hash del id cuando el bin esta sobrecargado.
/// 4. theta = start_angle + (sector + 0.5) * (2*PI / n_sectors) + jitter
/// 5. x = center.x + r * cos(theta); y = center.y + r * sin(theta)
///
/// Ring 0 con radio interno 0: usa r_inner = 30.0f para no colocar nodos
/// en el origen exacto (reservado para HUD overlay).
std::vector<LayoutOutput>
compute_ring_layout(const std::vector<LayoutInput>& nodes,
const LayoutConfig& cfg,
const StatusRingMap& status_map = {},
const DomainOrder& domain_order = {});
} // namespace fn_ring
+90
View File
@@ -0,0 +1,90 @@
---
name: compute_ring_layout
kind: function
lang: cpp
domain: core
purity: pure
version: "1.0.0"
signature: "std::vector<LayoutOutput> compute_ring_layout(const std::vector<LayoutInput>& nodes, const LayoutConfig& cfg, const StatusRingMap& status_map, const DomainOrder& domain_order)"
description: "Calcula posiciones (x,y) deterministicas para layout en anillos concentricos por status + sectores radiales por domain. Pure, sin fisicas, output reproducible. Util para skill_tree, dashboards de roadmap, mapas de capability."
tags: [layout, rings, sectors, polar, dashboard, registry, skill-tree]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["core/compute_ring_layout.h"]
params:
- name: nodes
desc: "Vector de LayoutInput. Cada nodo tiene id (unico), status (bucket de ring), domain (sector), recency (0..1, ordena dentro del bin desc)."
- name: cfg
desc: "LayoutConfig: n_sectors, center_x/y, ring_radii (bordes de los anillos), bin_padding, start_angle. Defaults validos para 5 rings / 18 sectores / canvas 850px de radio."
- name: status_map
desc: "Mapa status→ring. Vector vacio usa el mapa canonico (completado→0, in-progress→1, unlocked→2, pendiente→3, deferred→4). Status no encontrado → ring=-1 (descartado)."
- name: domain_order
desc: "Lista ordenada de domains para asignar sector. Domain fuera de la lista → sector n_sectors-1 (sector 'otros'). Vector vacio → todos los domains caen en sector n_sectors-1."
output: "Vector de LayoutOutput en el mismo orden que nodes[]. Cada salida: id, x, y (posicion en world units), ring (-1=descartado), sector, rank_in_bin (orden dentro del (ring,sector))."
tested: true
tests:
- "empty_input_empty_output"
- "single_node_centered_in_bin"
- "two_nodes_same_bin_radial_distribution"
- "default_status_map"
- "unmapped_status_returns_ring_minus_one"
- "domain_not_in_order_falls_back_to_last_sector"
- "deterministic_repeated_call"
- "ring_zero_avoids_origin"
- "sector_wrap_around_last_sector"
- "golden_snapshot_30_nodes"
test_file_path: "cpp/tests/test_compute_ring_layout.cpp"
file_path: "cpp/functions/core/compute_ring_layout.cpp"
---
## Ejemplo
```cpp
#include "core/compute_ring_layout.h"
fn_ring::LayoutConfig cfg;
// cfg usa defaults: 5 rings, 18 sectors, ring_radii={0,150,280,450,650,850}
fn_ring::DomainOrder order = {"core", "infra", "finance", "datascience"};
std::vector<fn_ring::LayoutInput> nodes = {
{"0001", "completado", "core", 1.0f},
{"0002", "pendiente", "infra", 0.5f},
{"0003", "in-progress", "finance", 0.8f},
{"0004", "deferred", "core", 0.1f},
};
auto out = fn_ring::compute_ring_layout(nodes, cfg, {}, order);
// out[0]: ring=0, sector=0 (completado → ring0, core → sector0)
// out[1]: ring=3, sector=1 (pendiente → ring3, infra → sector1)
// out[2]: ring=1, sector=2 (in-progress→ ring1, finance → sector2)
// out[3]: ring=4, sector=0 (deferred → ring4, core → sector0)
// Integrar en main.cpp del skill_tree:
for (auto& o : out) {
if (o.ring < 0) continue; // nodo descartado
// ImGui::SetCursorScreenPos({base.x + o.x, base.y + o.y});
// draw_node(o.id);
}
```
## Cuando usarla
Cuando necesitas colocar N entidades en un mapa polar de progreso donde el anillo codifica el estado de avance (completado → centro, pendiente → exterior) y el sector codifica el dominio o categoria. Casos canonicos: skill_tree de issues/capabilities, roadmap visual de un proyecto, mapa de cobertura del registry por dominio.
La funcion calcula posiciones una vez; la animacion (interpolacion entre dos snapshots) la hace el caller en main.cpp, no esta funcion.
## Gotchas
- **Hash deterministico dentro de la build**: el sub-jitter angular usa FNV-1a 32-bit sobre el id (no `std::hash<string>`), lo que garantiza estabilidad cross-platform dentro del mismo proceso. El output es reproducible entre compiladores distintos.
- **Domain fuera de DomainOrder**: siempre cae en sector `n_sectors-1`. Si el caller quiere que todos los domains inesperados esten dispersos en lugar de apilados en el ultimo sector, debe pasar un `DomainOrder` exhaustivo o usar un sector dedicado `"otros"`.
- **Ring 0 con `ring_radii[0]==0`**: la funcion usa `r_inner=30.0f` automaticamente para no colocar nodos en el origen. Si el caller quiere usar el origen, debe pasar `ring_radii[0] > 0`.
- **Bins muy densamente cargados**: cuando el bin tiene mas nodos que la capacidad radial (`band_height / 18px`), se activa jitter angular `±0.4 * half_sector`. Los nodos siguen dentro del sector pero el angulo no es exactamente el centro del sector. Para bins con N > ~20 nodos el solapamiento visual es inevitable sin escalado del canvas.
- **`status_map` vacio**: se usa el mapa canonico con 5 rings. Si el caller usa status propios, DEBE pasar su propio `StatusRingMap`; de lo contrario todos cairan en ring=-1 (descartados).
## Capability growth log
(sin bumps aun — v1.0.0)
+97 -24
View File
@@ -5,6 +5,8 @@
// v1.4.0: ChipRule / ColorStop / CategoricalChip / ColorScale renderers.
#pragma once
#include "compute_column_stats.h"
#include <string>
#include <utility>
#include <vector>
@@ -49,13 +51,68 @@ struct Filter {
std::string value;
};
// ColorStop: one stop in an N-color gradient. Used by ColorRule (NumericRange)
// y por ColumnSpec (ColorScale renderer). Definido aqui (no abajo) para que
// ColorRule pueda contenerlo sin forward decl.
struct ColorStop {
float position; // 0.0 (leftmost/min) to 1.0 (rightmost/max)
std::string color; // "#rrggbb" hex color at this stop
};
// ----------------------------------------------------------------------------
// Drill extendido (fase 10). Ver issue 0079.
// Definidos aqui (no al final) para que State pueda contener vector<DrillStep>.
// ----------------------------------------------------------------------------
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
// Step de drill grabado para history undo/redo (fase 10).
struct DrillStep {
int target_stage = -1; // stage donde se anadio el filter
int filter_pos = -1; // index en target_stage.filters
int prev_active_stage = 0; // active_stage antes del drill
Filter added; // filter para redo
};
// ----------------------------------------------------------------------------
// ColorRule kind (v1.5.0): pintado condicional con tres modos.
// CellBg — bg de celda cuando valor == `equals` (back-compat).
// CategoricalDot — dot a la izquierda del texto, colores autoasignados por
// valor distinto (palette deterministica). Util para
// categoricas con muchos valores donde no quieres definir
// cada color a mano.
// NumericRange — gradiente continuo de N colores (range_stops) sobre el
// bg de la celda. Para columnas numericas.
// ----------------------------------------------------------------------------
enum class ColorRuleKind : uint8_t {
CellBg = 0, // legacy (equals + color)
CategoricalDot = 1, // dots autoasignados por valor distinto
NumericRange = 2, // gradiente N-color sobre rango numerico
};
// ColorRule: pintado condicional de celdas (UI helper).
// ----------------------------------------------------------------------------
// v1.5.0: anade `kind` + campos para CategoricalDot / NumericRange.
struct ColorRule {
int col;
std::string equals;
unsigned int color;
int col;
std::string equals; // CellBg: match value; otros: ignorado
unsigned int color; // CellBg: bg color; otros: ignorado
// v1.5.0 fields (defaults preservan back-compat — kind=CellBg).
ColorRuleKind kind = ColorRuleKind::CellBg;
// CategoricalDot: alpha del relleno del dot (0..1). Tamaño en px (default 6).
float dot_alpha = 1.0f;
float dot_radius_px = 4.0f;
// Si vacio -> autoasigna desde palette interna. Si no, mapping fijo:
// pares (valor, "#rrggbb") consultados en orden.
std::vector<std::pair<std::string, std::string>> dot_map;
// NumericRange: gradiente continuo. range_min..range_max + N>=2 stops.
double range_min = 0.0;
double range_max = 1.0;
float range_alpha = 0.25f; // [0..1]; bg tint opacity
std::vector<ColorStop> range_stops; // declarado mas abajo; vacio -> default green→amber→red
};
// ----------------------------------------------------------------------------
@@ -189,11 +246,7 @@ struct ChipRule {
std::string color; // "#rrggbb" hex color for the filled circle
};
// ColorStop: one stop in an N-color gradient for ColorScale renderer (v1.4.0).
struct ColorStop {
float position; // 0.0 (leftmost/min) to 1.0 (rightmost/max)
std::string color; // "#rrggbb" hex color at this stop
};
// (ColorStop defined earlier, near Filter, so ColorRule can reference it.)
// ColumnSpec: rendering spec for one column. Indexed by column position.
struct ColumnSpec {
@@ -331,6 +384,41 @@ struct State {
bool chrome_user_set = true;
bool chrome_user_visible = false;
// ------------------------------------------------------------------------
// v1.5.0 — per-table UI state. Antes vivia en UiState singleton, lo cual
// hacia que toggle "Show stats" / seleccion de celdas / drill-history /
// row inspector se aplicasen a TODAS las tablas a la vez. Ahora cada
// State los lleva. Modal/popup state (ask AI, edit chips, etc.) sigue en
// UiState porque solo uno esta abierto en toda la app a la vez.
// ------------------------------------------------------------------------
// Stats panel (cabecera de col muestra min/max/percentiles/hist).
bool stats_mode = false;
std::vector<ColStats> stats_cache;
// Invalidacion del cache (data-identity y filter-set).
const char* const* stats_last_cells = nullptr;
int stats_last_rows = -1;
int stats_last_eff_cols = -1;
size_t stats_last_filter_h = (size_t)-1;
int stats_last_visible = -1;
// Cell selection (drag-select rectangular Ctrl+C-able).
int sel_anchor_row = -1;
int sel_anchor_col = -1;
int sel_end_row = -1;
int sel_end_col = -1;
bool sel_active = false;
bool sel_dragging = false;
// Row inspector modal target (-1 = closed).
int inspect_row = -1;
bool inspect_open = false;
// Drill history (fase 10) — per-table porque cada tabla tiene su propio
// pipeline de filters.
std::vector<DrillStep> drill_back;
std::vector<DrillStep> drill_forward;
// Helpers (definidos en compute_stage.cpp).
Stage& raw();
const Stage& raw() const;
@@ -339,19 +427,4 @@ struct State {
void ensure_stage0();
};
// ----------------------------------------------------------------------------
// Drill extendido (fase 10). Ver issue 0079.
// ----------------------------------------------------------------------------
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
// Step de drill grabado para history undo/redo (fase 10).
struct DrillStep {
int target_stage = -1; // stage donde se anadio el filter
int filter_pos = -1; // index en target_stage.filters
int prev_active_stage = 0; // active_stage antes del drill
Filter added; // filter para redo
};
} // namespace data_table
+251
View File
@@ -0,0 +1,251 @@
#include "core/parse_md_frontmatter.h"
#include <algorithm>
#include <sstream>
namespace fn_md {
namespace {
// ---------------------------------------------------------------------------
// String helpers
// ---------------------------------------------------------------------------
static std::string trim(const std::string& s) {
const auto b = s.find_first_not_of(" \t\r");
if (b == std::string::npos) return {};
const auto e = s.find_last_not_of(" \t\r");
return s.substr(b, e - b + 1);
}
// Strip a trailing ` # comment` from a value string.
// Only strips if the `#` is preceded by at least one space and is outside
// any quote context. We take a simple approach: after stripping outer quotes
// we look for ` #` outside of them.
static std::string strip_comment(const std::string& s) {
// If the whole string is quoted, skip comment stripping; the comment would
// be inside the quotes and should be preserved as literal text.
if (s.size() >= 2 &&
((s.front() == '"' && s.back() == '"') ||
(s.front() == '\'' && s.back() == '\''))) {
return s;
}
const auto pos = s.find(" #");
if (pos == std::string::npos) return s;
return trim(s.substr(0, pos));
}
// Remove surrounding " or ' quotes from a value, if present.
static std::string unquote(const std::string& s) {
if (s.size() >= 2 &&
((s.front() == '"' && s.back() == '"') ||
(s.front() == '\'' && s.back() == '\''))) {
return s.substr(1, s.size() - 2);
}
return s;
}
// Parse an inline YAML list: [a, b, c] or []
static std::vector<std::string> parse_inline_list(const std::string& s) {
std::vector<std::string> result;
// strip brackets
const auto lb = s.find('[');
const auto rb = s.rfind(']');
if (lb == std::string::npos || rb == std::string::npos || lb >= rb)
return result;
const std::string inner = s.substr(lb + 1, rb - lb - 1);
if (trim(inner).empty()) return result;
// split on commas respecting quotes
std::string token;
bool in_quote = false;
char quote_ch = 0;
for (char c : inner) {
if (!in_quote && (c == '"' || c == '\'')) {
in_quote = true;
quote_ch = c;
token += c;
} else if (in_quote && c == quote_ch) {
in_quote = false;
token += c;
} else if (!in_quote && c == ',') {
result.push_back(unquote(trim(token)));
token.clear();
} else {
token += c;
}
}
const auto t = unquote(trim(token));
if (!t.empty()) result.push_back(t);
return result;
}
// Split a frontmatter line into (key, raw_value).
// Returns false if the line has no `:`.
static bool split_kv(const std::string& line, std::string& key, std::string& raw_val) {
const auto colon = line.find(':');
if (colon == std::string::npos) return false;
key = trim(line.substr(0, colon));
raw_val = trim(line.substr(colon + 1));
return true;
}
// ---------------------------------------------------------------------------
// Line iterator helpers
// ---------------------------------------------------------------------------
static std::vector<std::string> split_lines(const std::string& s) {
std::vector<std::string> lines;
std::istringstream ss(s);
std::string l;
while (std::getline(ss, l)) lines.push_back(l);
return lines;
}
// Returns true if a line looks like a block-list item: optional leading
// whitespace, then `- `.
static bool is_list_item(const std::string& line, std::string& item_val) {
const auto b = line.find_first_not_of(" \t");
if (b == std::string::npos) return false;
if (line[b] != '-') return false;
// Must have space (or be end) after `-`
if (b + 1 < line.size() && line[b + 1] != ' ') return false;
item_val = trim(line.substr(b + 1));
item_val = unquote(item_val);
return true;
}
} // anonymous namespace
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
Frontmatter parse_md_frontmatter(const std::string& content) {
Frontmatter fm;
const auto lines = split_lines(content);
if (lines.empty()) return fm;
// Check opening `---`
if (trim(lines[0]) != "---") {
fm.body = content;
return fm;
}
fm.has_frontmatter = true;
// Find closing `---`
std::size_t close_idx = std::string::npos;
for (std::size_t i = 1; i < lines.size(); ++i) {
if (trim(lines[i]) == "---") {
close_idx = i;
break;
}
}
// Determine where the body starts (after the closing `---\n`)
if (close_idx == std::string::npos) {
// No closing delimiter: treat everything after line 0 as frontmatter,
// body is empty.
close_idx = lines.size();
} else {
// Reconstruct body from lines after close_idx
std::string body;
for (std::size_t i = close_idx + 1; i < lines.size(); ++i) {
body += lines[i] + '\n';
}
fm.body = body;
}
// Parse frontmatter lines [1 .. close_idx)
std::size_t i = 1;
while (i < close_idx) {
const std::string& line = lines[i];
// Skip blank lines and top-level comment lines
const std::string tl = trim(line);
if (tl.empty() || tl[0] == '#') {
++i;
continue;
}
std::string key, raw_val;
if (!split_kv(line, key, raw_val)) {
++i;
continue;
}
if (key.empty()) { ++i; continue; }
// --- Determine the kind of value ---
// 1. Inline list: raw_val starts with `[`
if (!raw_val.empty() && raw_val[0] == '[') {
fm.fields[key] = parse_inline_list(raw_val);
++i;
continue;
}
// 2. Block scalar `|` or `>` — unsupported, store empty string
if (raw_val == "|" || raw_val == ">") {
fm.fields[key] = std::string{};
// Skip continuation lines (more indented than current)
++i;
while (i < close_idx) {
const std::string& next = lines[i];
if (next.empty()) { ++i; continue; }
if (next[0] == ' ' || next[0] == '\t') { ++i; continue; }
break;
}
continue;
}
// 3. Block list: raw_val is empty and next line(s) are list items
if (raw_val.empty()) {
// Peek ahead
std::string dummy;
std::size_t j = i + 1;
bool found_items = false;
while (j < close_idx) {
const std::string& next = lines[j];
if (trim(next).empty()) { ++j; continue; }
std::string item_val;
if (is_list_item(next, item_val)) {
found_items = true;
break;
}
break;
}
if (found_items) {
std::vector<std::string> items;
++i; // move past the key line
while (i < close_idx) {
const std::string& next = lines[i];
if (trim(next).empty()) { ++i; continue; }
std::string item_val;
if (is_list_item(next, item_val)) {
items.push_back(unquote(item_val));
++i;
} else {
break; // end of block list
}
}
fm.fields[key] = std::move(items);
continue;
}
// Empty value (e.g. `key:` with no list following)
fm.fields[key] = std::string{};
++i;
continue;
}
// 4. Plain scalar (strip trailing comment, then unquote)
const std::string stripped = strip_comment(raw_val);
fm.fields[key] = unquote(trim(stripped));
++i;
}
return fm;
}
} // namespace fn_md
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <unordered_map>
#include <variant>
#include <vector>
namespace fn_md {
/// A YAML value: absent, scalar string, or list of strings.
using YamlValue = std::variant<std::monostate, std::string, std::vector<std::string>>;
/// Result of parsing a Markdown file that may contain a YAML frontmatter block.
struct Frontmatter {
std::unordered_map<std::string, YamlValue> fields;
std::string body; ///< content after the closing `---` line
bool has_frontmatter = false;
};
/// Parse a Markdown file that may (or may not) begin with a YAML frontmatter
/// block delimited by `---` lines.
///
/// Supported YAML subset (flat only, no nested maps):
/// key: value -> string (bare, or "quoted", or 'quoted')
/// key: [a, b, c] -> inline list -> vector<string>
/// key: -> block list when the next indented lines are
/// - item ` - item` -> vector<string>
/// key: | -> unsupported block scalar; stored as ""
/// # comment -> ignored (also inline trailing comments)
///
/// Notes:
/// - Keys and values are trimmed of leading/trailing whitespace.
/// - A value like `description: "foo: bar"` splits on the FIRST colon only.
/// - Pure function: no I/O, no logging, no side-effects.
Frontmatter parse_md_frontmatter(const std::string& content);
} // namespace fn_md
@@ -0,0 +1,82 @@
---
name: parse_md_frontmatter
kind: function
lang: cpp
domain: core
version: "1.0.0"
purity: pure
signature: "Frontmatter parse_md_frontmatter(const std::string& content)"
description: "Parsea frontmatter YAML simple (subset: key:value, listas inline [a,b], listas multilinea con - item) de un .md y devuelve struct con fields map + body. Pure, sin dependencias externas."
tags: [markdown, frontmatter, yaml, parser, issues, meta, registry]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["core/parse_md_frontmatter.h"]
example: |
#include "core/parse_md_frontmatter.h"
#include <fstream>
#include <sstream>
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::stringstream ss; ss << f.rdbuf();
auto fm = fn_md::parse_md_frontmatter(ss.str());
auto title = std::get<std::string>(fm.fields["title"]);
auto domain = std::get<std::vector<std::string>>(fm.fields["domain"]);
tested: true
tests:
- "parses_no_frontmatter"
- "parses_simple_key_value"
- "parses_quoted_strings"
- "parses_inline_list"
- "parses_multiline_list"
- "parses_body_after_frontmatter"
- "parses_empty_inline_list"
- "parses_strips_trailing_comment"
- "parses_real_issue_0109"
- "parses_real_issues_golden"
test_file_path: "cpp/tests/test_parse_md_frontmatter.cpp"
file_path: "cpp/functions/core/parse_md_frontmatter.cpp"
params:
- name: content
desc: "Contenido completo del archivo .md como string. Puede o no comenzar con bloque frontmatter `---...---`."
output: "Struct Frontmatter con: fields (unordered_map<string, YamlValue>) donde YamlValue = monostate|string|vector<string>; body (string con contenido despues del segundo ---); has_frontmatter (bool)."
notes: |
Subset YAML deliberado — los frontmatters de issues y flows del registry son planos. Si en el futuro hace falta soportar mapas anidados, considerar embeber yaml-cpp en cpp/vendor/ antes que extender este parser.
---
## Cuando usarla
Cuando una app C++ necesita leer metadata de archivos Markdown del registry (issues, flows, app.md, analysis.md, type.md). No requiere libreria externa ni yaml-cpp.
## Gotchas
- Subset YAML: NO soporta mapas anidados, NO soporta block scalars `|` ni `>` (devuelve string vacio en esos casos).
- Si un value contiene `: ` literal debe ir entre comillas: `description: "foo: bar"`.
- Comentarios solo se eliminan al final de linea en valores no citados. Un `# comentario` dentro de comillas se preserva como literal.
- Block list: las lineas de items deben estar indentadas con al menos un espacio antes de `- `. Un `- item` sin indentacion al nivel raiz se ignora (no es list item de la clave anterior).
- `YamlValue` es `std::variant` — usar `std::get<std::string>` o `std::get<std::vector<std::string>>` segun el campo. `std::get_if` es mas seguro si el tipo no es conocido a priori.
## Ejemplo
```cpp
#include "core/parse_md_frontmatter.h"
#include <fstream>
#include <sstream>
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::stringstream ss; ss << f.rdbuf();
auto fm = fn_md::parse_md_frontmatter(ss.str());
// Scalar string
auto id = std::get<std::string>(fm.fields["id"]); // "0109"
auto status = std::get<std::string>(fm.fields["status"]); // "in-progress"
// List
auto domain = std::get<std::vector<std::string>>(fm.fields["domain"]);
// domain[0] == "meta", domain[1] == "cpp-stack"
// Body (content after closing ---)
// fm.body starts with "\n# 0109 — skill_tree app..."
```
+188
View File
@@ -0,0 +1,188 @@
// data_table_ai_panel — modal "Ask AI" de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Rangos de lineas del fuente original (antes de la extraccion):
// - Stub llm_anthropic (no-op) : lineas 82-112
// - Modal "Ask AI" completo : lineas 4209-4328
// - Boton trigger en viz selector: linea 1588
//
// NOTA DEUDA TECNICA: llm_anthropic no esta en el registry todavia (Wave 4 TODO).
// Pendiente promover a cpp/functions/infra/llm_anthropic (.h + .cpp + .md).
#include "viz/data_table_ai_panel.h"
#include "core/data_table_types.h"
#include "core/tql_apply.h"
#include "imgui.h"
// llm_anthropic: usar header real si disponible, stub si no.
#ifdef FN_LLM_ANTHROPIC
# include "core/llm_anthropic.h"
#else
// Stub no-op (inline en este TU hasta que llm_anthropic se promueva al registry).
// TODO: Wave 4 — promover a cpp/functions/infra/llm_anthropic.cpp + .h + .md
namespace llm_anthropic {
enum class OutputMode { TQL, SQL };
struct AskInput {
std::string question;
std::string tql_current;
std::vector<std::string> col_names;
std::vector<data_table::ColumnType> col_types;
std::vector<std::string> joinable_names;
OutputMode mode = OutputMode::TQL;
std::string model;
int max_tokens = 8192;
};
struct AskResult {
std::string code;
std::string raw;
std::string error;
int tokens_in = 0;
int tokens_out = 0;
};
inline AskResult ask(const AskInput&, const std::string& = "") {
AskResult r;
r.error = "llm_anthropic not available (stub). Build with FN_LLM_ANTHROPIC=1.";
return r;
}
} // namespace llm_anthropic
#endif
#include <string>
#include <vector>
#include <cstdio>
namespace data_table {
// ---------------------------------------------------------------------------
// draw_ask_ai_modal
// Dibuja el modal "Ask AI". Debe llamarse cada frame (patron ImGui).
// Abre el popup si ask_ai.open == true.
// ---------------------------------------------------------------------------
void draw_ask_ai_modal(AskAiState& ask_ai,
State& st,
const std::vector<std::string>& active_headers,
const std::vector<ColumnType>& active_types,
int orig_cols)
{
if (ask_ai.open) ImGui::OpenPopup("Ask AI");
ImGui::SetNextWindowSize(ImVec2(820, 560), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("Ask AI", &ask_ai.open,
ImGuiWindowFlags_NoSavedSettings)) {
ImGui::TextDisabled("Ask en lenguaje natural. Default TQL. SQL solo si DuckDB linkado.");
const char* modes[] = {"TQL", "SQL (DuckDB)"};
#ifndef FN_TQL_DUCKDB
// SQL mode disabled visually pero el toggle existe (informativo)
if (ask_ai.mode == 1) ask_ai.mode = 0;
#endif
ImGui::Combo("Output##askmode", &ask_ai.mode, modes, IM_ARRAYSIZE(modes));
#ifndef FN_TQL_DUCKDB
if (ask_ai.mode == 1) {
ImGui::TextColored(ImVec4(1, 0.5f, 0.3f, 1),
"SQL mode requires FN_TQL_DUCKDB=1 build flag.");
}
#endif
ImGui::InputTextMultiline("##ask_q", ask_ai.question, sizeof(ask_ai.question),
ImVec2(-1, 80));
ImGui::BeginDisabled(ask_ai.busy);
if (ImGui::Button("Send")) {
ask_ai.busy = true;
ask_ai.status = "Sending...";
ask_ai.error.clear();
ask_ai.response_code.clear();
ask_ai.response_raw.clear();
// Build AskInput desde el state actual.
llm_anthropic::AskInput in;
in.question = ask_ai.question;
in.tql_current = ask_ai.current_tql;
in.col_names = active_headers;
in.col_types = active_types;
in.mode = (ask_ai.mode == 1)
? llm_anthropic::OutputMode::SQL
: llm_anthropic::OutputMode::TQL;
// Llamada blocking (UI freeze breve durante red).
auto r = llm_anthropic::ask(in);
ask_ai.busy = false;
if (!r.error.empty()) {
ask_ai.error = r.error;
ask_ai.status = "Error";
} else {
ask_ai.response_raw = r.raw;
ask_ai.response_code = r.code;
ask_ai.status = "Got response.";
// Llenar edit buffer
std::snprintf(ask_ai.edit_buf, sizeof(ask_ai.edit_buf),
"%s", r.code.c_str());
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (!ask_ai.status.empty()) {
ImGui::TextDisabled("%s", ask_ai.status.c_str());
}
if (!ask_ai.error.empty()) {
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", ask_ai.error.c_str());
}
ImGui::Separator();
ImGui::Columns(2, "ask_cols", true);
ImGui::TextUnformatted("Current");
ImGui::InputTextMultiline("##ask_cur",
const_cast<char*>(ask_ai.current_tql.c_str()),
ask_ai.current_tql.size() + 1,
ImVec2(-1, 240),
ImGuiInputTextFlags_ReadOnly);
ImGui::NextColumn();
ImGui::TextUnformatted("Proposed (editable before apply)");
ImGui::InputTextMultiline("##ask_new", ask_ai.edit_buf, sizeof(ask_ai.edit_buf),
ImVec2(-1, 240));
ImGui::Columns(1);
bool can_apply = !ask_ai.busy && ask_ai.edit_buf[0] != '\0';
ImGui::BeginDisabled(!can_apply);
if (ImGui::Button("Apply")) {
std::string err;
if (ask_ai.mode == 0) {
// TQL apply
bool ok = tql::apply(ask_ai.edit_buf, st,
active_headers,
active_types,
nullptr, 0,
orig_cols,
&err);
if (ok) {
ask_ai.status = "Applied OK.";
ask_ai.open = false;
} else {
ask_ai.error = "tql::apply error: " + err;
ask_ai.status = "Apply failed.";
}
} else {
#ifdef FN_TQL_DUCKDB
// SQL apply: ejecutar via tql_duckdb sobre TableInputs activas.
// Para tablas en memoria construimos un TableInput basico desde
// active_headers/types. v1 no recupera cells originales aqui;
// reportamos solo error si fallo. Caller real deberia pasar
// tables() del render scope. Sin esto, marcamos status info.
ask_ai.status = "SQL execute disponible (FN_TQL_DUCKDB ON). "
"Integracion full pendiente: usar tql_duckdb::execute desde caller.";
#else
ask_ai.status = "SQL execute requires FN_TQL_DUCKDB build flag.";
#endif
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Reject")) {
ask_ai.response_code.clear();
ask_ai.edit_buf[0] = '\0';
}
ImGui::SameLine();
if (ImGui::Button("Close")) {
ask_ai.open = false;
}
ImGui::EndPopup();
}
}
} // namespace data_table
+61
View File
@@ -0,0 +1,61 @@
#pragma once
// data_table_ai_panel — modal "Ask AI" de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// - draw_ask_ai_modal: modal ImGui con prompt de lenguaje natural, llamada
// a llm_anthropic::ask, render de la respuesta (codigo TQL/SQL editable),
// botones Apply/Reject/Close, export CSV de la respuesta.
//
// El modal usa UiState.ask_* que se mueve aqui (AskAiState) para
// encapsulacion. Vive en UiState del data_table principal — no en State.
//
// Dependencia con llm_anthropic:
// - Si FN_LLM_ANTHROPIC esta definido: usa core/llm_anthropic.h (real).
// - Si no: el stub del data_table.cpp provee tipos/funciones no-op.
// - Tras la extraccion este modulo incluye el stub o el header real.
//
// Rangos del fuente original:
// - Modal "Ask AI" : lineas 4636-4755
// - Boton trigger : linea 1869 (dentro de draw_viz_selector)
//
// Dependencias: data_table_types.h, llm_anthropic.h (o stub), imgui.h.
#include "core/data_table_types.h"
#include "imgui.h"
#include <string>
#include <vector>
namespace data_table {
// AskAiState: estado del modal Ask AI. Debe persistir entre frames
// (es parte de UiState). Extraido aqui para encapsulacion.
struct AskAiState {
bool open = false;
bool busy = false;
int mode = 0; // 0 = TQL, 1 = SQL (DuckDB)
char question[2048] = {};
std::string current_tql; // TQL emitido del state al abrir modal
std::string response_raw; // texto raw del modelo
std::string response_code; // bloque extraido (Lua o SQL)
std::string error;
std::string status; // "Sending..." / "Got response." / error
char edit_buf[8192] = {}; // buffer editable de la propuesta
};
// draw_ask_ai_modal — dibuja el modal "Ask AI".
// Debe llamarse cada frame (pattern ImGui). Abre el popup si ask_ai.open==true.
//
// Parametros:
// ask_ai — estado mutable del modal.
// st — State principal (para tql::apply en Apply + active_headers/types).
// active_headers / active_types — snapshot del output activo (para la llamada llm).
// orig_cols — numero de cols originales (para tql::apply signature).
// ---------------------------------------------------------------------------
void draw_ask_ai_modal(AskAiState& ask_ai,
State& st,
const std::vector<std::string>& active_headers,
const std::vector<ColumnType>& active_types,
int orig_cols);
} // namespace data_table
+80
View File
@@ -0,0 +1,80 @@
---
name: data_table_ai_panel
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::draw_ask_ai_modal(AskAiState& ask_ai, State& st, const std::vector<std::string>& active_headers, const std::vector<ColumnType>& active_types, int orig_cols)"
description: "Modal 'Ask AI' de la tabla TQL: UI de prompt en lenguaje natural, llamada bloqueante a llm_anthropic::ask, render de la respuesta (codigo TQL o SQL editable), botones Apply/Reject/Close. El Apply ejecuta tql::apply sobre el estado actual de la tabla. Soporta modo TQL (default) y SQL (requiere FN_TQL_DUCKDB). Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c, fase 11 / issue 0080)."
tags: [viz, table, imgui, ui, ai, llm, tql, cpp-tables, ask-ai]
uses_functions:
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_ai_panel.cpp"
framework: imgui
params:
- name: ask_ai
desc: "Estado mutable del modal (AskAiState): prompt, modo TQL/SQL, buffers de respuesta, flags open/busy. Debe persistir entre frames (vive en UiState del data_table)."
- name: st
desc: "State principal de la tabla. Mutado por Apply: tql::apply modifica st.stages, filtros, sorts."
- name: active_headers / active_types
desc: "Snapshot del output del stage activo en el momento de abrir el modal. Se pasan a llm_anthropic::AskInput para que el modelo conozca el schema."
- name: orig_cols
desc: "Numero de columnas originales. Necesario para la firma de tql::apply."
output: "Void. Efectos: mutates ask_ai (flags, buffers), mutates st (via tql::apply en Apply)."
---
## Documentacion
Sub-funcion que encapsula el modal "Ask AI" de la tabla TQL (issue 0080, fase 11). Permite al usuario escribir una pregunta en lenguaje natural y recibir codigo TQL o SQL generado por un modelo de lenguaje.
### Flujo del modal
1. `ask_ai.open = true` (seteado por boton "Ask AI" en `draw_viz_selector`).
2. Primer frame con `open=true`: `ImGui::OpenPopup("Ask AI")`.
3. Modal se abre: prompt input, combo modo TQL/SQL.
4. Usuario escribe pregunta y hace click en "Send":
- Construye `llm_anthropic::AskInput` con `question`, `col_names`, `col_types`, `tql_current`.
- Llamada **bloqueante** a `llm_anthropic::ask(in)``AskResult`.
- Si error: `ask_ai.error` se muestra en rojo.
- Si ok: `ask_ai.response_code` se copia a `ask_ai.edit_buf` (editable).
5. Panel dividido en dos columnas: TQL actual (read-only) | propuesta (editable).
6. Botones:
- **Apply**: ejecuta `tql::apply(ask_ai.edit_buf, st, ...)`. Si ok: cierra modal.
- **Reject**: limpia `edit_buf` y `response_code`.
- **Close**: `ask_ai.open = false`.
### Dependencia llm_anthropic
Si `FN_LLM_ANTHROPIC` esta definido en el build, se incluye `core/llm_anthropic.h` (real). Si no, se usa el stub no-op que retorna error "not available". Pendiente promover a `cpp/functions/infra/llm_anthropic` (Wave 4, issue 0107c deuda tecnica).
## Ejemplo
```cpp
// En el render principal, llamar una vez por frame tras el grid:
data_table::draw_ask_ai_modal(U.ask_ai, st,
U.active_headers, U.active_types, orig_cols);
// El boton de apertura vive en draw_viz_selector:
// if (ImGui::SmallButton("Ask AI##ask_open")) U.ask_ai.open = true;
```
## Cuando usarla
Llamar una vez por frame desde el entrypoint thin `data_table::render()` en el bloque de modales (junto a TQL show/apply, Custom column, etc.), SIEMPRE que el contexto ImGui tenga la ventana activa. No llamar desde apps directamente.
## Gotchas
- La llamada a `llm_anthropic::ask` es **bloqueante** (red HTTP). La UI se congela brevemente. Consideracion futura: mover a hilo background + flag de estado asincrono.
- El modal se destruye (EndPopup) si el usuario hace click fuera. `ask_ai.open` se pone a false por el `&ask_ai.open` del `BeginPopupModal`. Esto borra el estado del prompt en curso — consideracion UX conocida.
- En modo SQL (`ask_ai.mode == 1`), Apply solo funciona si `FN_TQL_DUCKDB` esta definido; de lo contrario muestra mensaje de error informativo.
- `llm_anthropic` no esta en el registry (deuda Wave 4). Hasta que se promueva, el stub vive inlined en este .cpp. No duplicar el stub en otros archivos.
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
#pragma once
// data_table_chips — barra de chips superior de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// Toda la UI de chips que aparece en el area de chrome de la tabla:
// - draw_joins_chips : chips de join con tablas secundarias.
// - draw_filter_chips : chips de filtros activos (con boton de borrar).
// - draw_breakout_chips : chips de breakout (group-by) activos.
// - draw_aggregation_chips: chips de agregaciones activas.
// - draw_sort_chips : chips de ordenamiento activos.
// - draw_header_menu : popup menu al hacer right-click en cabecera de col.
// - apply_header_sort_click: procesa click en cabecera (sort / multi-sort con Shift).
//
// Popups de anadir/editar chips (activados desde los chips o botones "+"):
// - draw_add_filter_popup / draw_edit_filter_popup
// - draw_add_breakout_popup / draw_edit_breakout_popup
// - draw_add_aggregation_popup / draw_edit_agg_popup
// - draw_add_sort_popup / draw_edit_sort_popup
//
// TQL preview / save / load (area de chrome stage 0):
// - draw_tql_bar (NEW helper que engloba Show TQL, Apply TQL, Save/Load .tql)
//
// Helpers de tipo/op para los popups:
// - draw_typed_ops: dibuja radio buttons de Op segun ColumnType.
// - type_supports_range: retorna true si el tipo soporta Op::Range.
//
// Rangos del fuente original:
// - draw_joins_chips : lineas 1897-2003
// - draw_filter_chips : lineas 2009-2093
// - draw_breakout_chips : lineas 2095-2188
// - draw_aggregation_chips : lineas 2189-2238
// - draw_sort_chips : lineas 2240-2309
// - apply_header_sort_click : lineas 2311-2327
// - draw_edit_filter_popup : lineas 2329-2370
// - draw_edit_breakout_popup : lineas 2372-2395
// - draw_edit_agg_popup : lineas 2397-2445
// - draw_edit_sort_popup : lineas 2447-2472
// - draw_typed_ops : lineas 2503-2510
// - type_supports_range : lineas 2512-2514
// - draw_add_filter_popup : lineas 2516-2569
// - draw_add_breakout_popup : lineas 2571-2604
// - draw_add_aggregation_popup: lineas 2606-2655
// - draw_add_sort_popup : lineas 2657-2682
// - draw_header_menu : lineas 2684-2892
// - TQL preview/save/load : lineas 3272-3366 (dentro de render() stage 0)
//
// Dependencias: data_table_types.h, tql_emit.h, tql_apply.h, imgui.h.
#include "core/data_table_types.h"
#include "imgui.h"
#include <vector>
#include <string>
namespace data_table {
// ---------------------------------------------------------------------------
// Helpers de tipo/operacion
// ---------------------------------------------------------------------------
// draw_typed_ops: dibuja radio buttons para los operadores compatibles con el
// tipo `t`. Rellena `out` con el op seleccionado. Retorna true si se selecciono.
bool draw_typed_ops(ColumnType t, Op& out);
// type_supports_range: true si el tipo admite Op::Range (Int, Float, Date).
bool type_supports_range(ColumnType t);
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
// apply_header_sort_click: registra un click en el header de col_name.
// Sin Shift: sort primario (reemplaza todos los sorts).
// Con Shift: sort secundario (agrega al sort primario existente).
// Ciclo: Asc -> Desc -> none.
void apply_header_sort_click(Stage& stg, const std::string& col_name, bool shift);
void draw_sort_chips(Stage& stg);
void draw_add_sort_popup(Stage& stg, const char* const* headers, int n_cols,
const std::vector<ColumnType>& types);
void draw_edit_sort_popup(Stage& stg, const char* const* headers, int n_cols);
// ---------------------------------------------------------------------------
// Filtros
// ---------------------------------------------------------------------------
void draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols,
const std::vector<ColumnType>& eff_types);
void draw_add_filter_popup(Stage& stg, const char* const* eff_headers, int eff_cols,
const std::vector<ColumnType>& eff_types);
void draw_edit_filter_popup(Stage& stg, const char* const* headers, int n_cols,
const std::vector<ColumnType>& types);
// ---------------------------------------------------------------------------
// Breakouts (group-by)
// ---------------------------------------------------------------------------
void draw_breakout_chips(Stage& stg, const char* const* in_headers, int in_cols,
const std::vector<ColumnType>& in_types);
void draw_add_breakout_popup(Stage& stg, const char* const* in_headers, int in_cols,
const std::vector<ColumnType>& in_types,
const char* const* cur_cells, int cur_rows);
void draw_edit_breakout_popup(Stage& stg, const char* const* headers, int n_cols);
// ---------------------------------------------------------------------------
// Agregaciones
// ---------------------------------------------------------------------------
void draw_aggregation_chips(Stage& stg, const char* const* in_headers, int in_cols);
void draw_add_aggregation_popup(Stage& stg, const char* const* in_headers, int in_cols,
const std::vector<ColumnType>& in_types);
void draw_edit_agg_popup(Stage& stg, const char* const* headers, int n_cols);
// ---------------------------------------------------------------------------
// Joins
// ---------------------------------------------------------------------------
// draw_joins_chips: chips de join con las tablas joinables. Solo visible si
// joinables no esta vacio. Mutates st.stages y la configuracion de join.
void draw_joins_chips(State& st, const std::vector<TableInput>& joinables,
const char* const* eff_headers, int eff_cols,
const std::vector<ColumnType>& eff_types);
// ---------------------------------------------------------------------------
// Header menu (right-click en cabecera de columna)
// ---------------------------------------------------------------------------
// draw_header_menu: popup que aparece al right-click en el header de columna
// `col`. Incluye sort, filter, conditional color, type change, etc.
// is_raw_stage: true si estamos en stage 0 (permite "Change type" / "Derived").
void draw_header_menu(State& st, Stage& stg, int col,
const char* const* eff_headers_arr, int eff_cols,
const std::vector<ColumnType>& eff_types,
int orig_cols, bool is_raw_stage);
// ---------------------------------------------------------------------------
// TQL bar (Show TQL / Apply TQL / Save .tql / Load .tql)
// Area de chrome adicional en stage 0. Nuevo helper que extrae el bloque
// de lineas 3272-3366 del render() original.
// ---------------------------------------------------------------------------
// TqlBarState: estado del area TQL (vive en UiState).
struct TqlBarState {
bool show_open = false;
std::string show_text;
bool apply_open = false;
std::string apply_text;
std::string apply_error;
char file_path[256] = "table.tql";
std::string io_status; // "saved: ..." / "loaded: ..." / "load FAILED: ..."
};
// draw_tql_bar: dibuja los botones Show TQL, Apply TQL, Save .tql, Load .tql
// y los modales correspondientes. Mutates st via tql::apply en Apply.
// active_headers/types/cells/row_count/orig_cols: necesarios para tql::emit + apply.
void draw_tql_bar(TqlBarState& tql_bar,
State& st,
const std::vector<std::string>& orig_headers,
const std::vector<ColumnType>& orig_types,
const char* const* cells,
int row_count, int orig_cols);
} // namespace data_table
+116
View File
@@ -0,0 +1,116 @@
---
name: data_table_chips
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols, const std::vector<ColumnType>& eff_types)"
description: "Barra de chips superior de la tabla TQL: render y edicion de filtros activos, breakouts (group-by), agregaciones, sorts, joins con tablas secundarias, header-menu de columna (sort/filter/conditional-color/type-change), y area TQL (Show TQL / Apply TQL / Save .tql / Load .tql). Es la sub-funcion mas grande del refactor 0107c (~1000 LOC, 17 funciones). Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, chips, filters, sort, aggregation, tql, cpp-tables, joins]
uses_functions:
- data_table_color_rules_cpp_viz
- data_table_cpp_viz
- tql_emit_cpp_core
- tql_apply_cpp_core
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_chips.cpp"
framework: imgui
params:
- name: stg
desc: "Stage activo (st.stages[active]): contiene filters, breakouts, aggregations, sorts, derived. Mutado por todos los chips y popups de edicion."
- name: eff_headers / eff_cols
desc: "Headers y numero de columnas efectivas del stage activo (orig + derived). Usados en labels de chips y en los popups de anadir/editar."
- name: eff_types
desc: "Tipos de columna efectivos. Usados para filtrar los operadores disponibles (draw_typed_ops) y para formatear los popups."
- name: st (para joins y header-menu)
desc: "State completo: necesario para draw_joins_chips (accede a st.stages) y draw_header_menu (accede a st.color_rules, st.col_visible)."
- name: joinables (para joins)
desc: "Vector de TableInput secundarias disponibles para join. Si vacio, draw_joins_chips no muestra nada."
output: "Void. Todos los efectos son mutaciones de stg (Stage) o st (State) en respuesta a la interaccion del usuario."
---
## Documentacion
Sub-funcion mas grande del refactor 0107c. Encapsula toda la UI de la barra de chips de la tabla TQL.
### Mapa de funciones
| Funcion | LOC aprox | Responsabilidad |
|---|---|---|
| `draw_filter_chips` | ~85 | Chips de filtros activos con X para borrar |
| `draw_add_filter_popup` | ~55 | Popup para anadir filtro nuevo |
| `draw_edit_filter_popup` | ~42 | Popup edicion de filtro existente (right-click en chip) |
| `draw_breakout_chips` | ~93 | Chips de breakout activos con X |
| `draw_add_breakout_popup` | ~34 | Popup anadir breakout (col de agrupacion) |
| `draw_edit_breakout_popup` | ~24 | Popup edicion breakout |
| `draw_aggregation_chips` | ~50 | Chips de agregaciones activas con X |
| `draw_add_aggregation_popup` | ~50 | Popup anadir agregacion (Count, Sum, Mean, ...) |
| `draw_edit_agg_popup` | ~48 | Popup edicion agregacion |
| `draw_sort_chips` | ~70 | Chips de sort activos con X |
| `draw_add_sort_popup` | ~26 | Popup anadir sort |
| `draw_edit_sort_popup` | ~26 | Popup edicion sort |
| `apply_header_sort_click` | ~17 | Sort al click en cabecera (Asc/Desc/none ciclo) |
| `draw_joins_chips` | ~107 | Chips de joins con tablas secundarias |
| `draw_header_menu` | ~209 | Menu contextual de columna (sort/filter/color/type) |
| `draw_typed_ops` | ~8 | Radio buttons de Op segun tipo de columna |
| `type_supports_range` | ~3 | Bool tipo soporta rango |
| `draw_tql_bar` | ~95 | Botones + modales TQL show/apply/save/load |
### Orden de llamada en el chrome
```cpp
// Stage 0 (chrome_visible):
draw_joins_chips(st, joinables, hdrs, cols, types);
draw_filter_chips(act, hdrs, cols, types);
draw_add_filter_popup(act, hdrs, cols, types);
draw_edit_filter_popup(act, hdrs, cols, types);
draw_breakout_chips(act, hdrs, cols, types);
// ... etc.
draw_sort_chips(act);
draw_tql_bar(tql_bar, st, orig_headers, orig_types, cells, rows, orig_cols);
```
## Ejemplo
```cpp
// Bloque chrome stage 0:
if (chrome_visible) {
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2));
data_table::draw_filter_chips(act, eff_headers, eff_cols, eff_types);
data_table::draw_add_filter_popup(act, eff_headers, eff_cols, eff_types);
data_table::draw_edit_filter_popup(act, eff_headers, eff_cols, eff_types);
data_table::draw_breakout_chips(act, eff_headers, eff_cols, eff_types);
// ...
data_table::draw_sort_chips(act);
data_table::draw_add_sort_popup(act, eff_headers, eff_cols, eff_types);
data_table::draw_edit_sort_popup(act, eff_headers, eff_cols);
data_table::draw_tql_bar(U.tql_bar, st, orig_h, orig_t, cells, rows, orig_cols);
ImGui::PopStyleVar();
}
```
## Cuando usarla
Llamar desde el entrypoint thin `data_table::render()` en el bloque `chrome_visible`. El orden importa: joins primero, luego filtros, breakouts, agregaciones, sorts, TQL bar. No llamar fuera del contexto de un frame ImGui activo.
## Gotchas
- `draw_header_menu` DEBE llamarse desde dentro del popup de cabecera (`ImGui::BeginPopupContextItem`) — no se puede llamar en el loop normal de render.
- Los popups de add/edit usan IDs de ImGui fijos (`"##addf"`, `"##editf"`, etc.). Si se tienen multiples instancias de data_table en la misma ventana, usar `ImGui::PushID(table_id)` antes de llamar a los chips.
- `draw_tql_bar` incluye los modales "Show TQL" y "Apply TQL" via `ImGui::BeginPopupModal`. Deben llamarse fuera del bloque `PushStyleVar` si los estilos interfieren con el modal.
- `apply_header_sort_click` sin Shift reemplaza TODOS los sorts existentes por el nuevo. Es el comportamiento esperado para click simple. Shift agrega sort secundario.
- `draw_joins_chips` asume que `joinables` es la slice de `tables` excluyendo la tabla principal. Si se pasa la tabla principal como joinable, se creara un self-join.
@@ -0,0 +1,353 @@
// data_table_color_rules — editor de reglas de color por columna + aplicacion.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// - Helpers de color: auto_categorical_color, resolve_categorical_dot_color,
// apply_color_rules_for_cell.
// - UI del submenu "Conditional color" dentro del header-menu de columnas:
// draw_color_rule_menu.
//
// hex_to_imcolor, parse_hex_color, lerp_color_along_stops son helpers de color
// puros. Se definen como static aqui (no se exportan) porque data_table.cpp
// tiene sus propias versiones static tambien — ambos TU son independientes.
#include "viz/data_table_color_rules.h"
#include "data_table/data_table_internal.h"
#include "imgui.h"
#include "core/data_table_types.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <limits>
#include <vector>
namespace {
// hex_to_imcolor: "#rrggbb" -> ImVec4. Returns {-1,-1,-1,-1} on failure.
static ImVec4 hex_to_imcolor(const std::string& hex) {
const char* p = hex.c_str();
if (*p == '#') ++p;
unsigned int r = 0, g = 0, b = 0;
if (std::sscanf(p, "%02x%02x%02x", &r, &g, &b) != 3)
return ImVec4(-1.f, -1.f, -1.f, -1.f);
return ImVec4(r / 255.f, g / 255.f, b / 255.f, 1.f);
}
// lerp_color_along_stops: LERP entre N color stops en [0,1].
static ImU32 lerp_color_along_stops(
const std::vector<data_table::ColorStop>& stops, float t, float alpha)
{
static const std::vector<data_table::ColorStop> kDefault = {
{0.0f, "#22c55e"},
{0.5f, "#f59e0b"},
{1.0f, "#ef4444"},
};
const auto& sv = stops.empty() ? kDefault : stops;
std::vector<data_table::ColorStop> sorted_sv = sv;
std::sort(sorted_sv.begin(), sorted_sv.end(),
[](const data_table::ColorStop& a, const data_table::ColorStop& b){
return a.position < b.position;
});
t = t < 0.f ? 0.f : (t > 1.f ? 1.f : t);
if (t <= sorted_sv.front().position)
return data_table::parse_hex_color(sorted_sv.front().color, alpha);
if (t >= sorted_sv.back().position)
return data_table::parse_hex_color(sorted_sv.back().color, alpha);
for (size_t i = 0; i + 1 < sorted_sv.size(); ++i) {
const auto& lo = sorted_sv[i];
const auto& hi = sorted_sv[i + 1];
if (t >= lo.position && t <= hi.position) {
float span = hi.position - lo.position;
float f = (span > 1e-6f) ? (t - lo.position) / span : 0.f;
ImVec4 ca = hex_to_imcolor(lo.color);
ImVec4 cb = hex_to_imcolor(hi.color);
if (ca.x < 0.f) ca = ImVec4(0.5f, 0.5f, 0.5f, 1.f);
if (cb.x < 0.f) cb = ImVec4(0.5f, 0.5f, 0.5f, 1.f);
float r = ca.x + f * (cb.x - ca.x);
float g = ca.y + f * (cb.y - ca.y);
float b = ca.z + f * (cb.z - ca.z);
unsigned int ri = (unsigned int)(r * 255.f + 0.5f);
unsigned int gi = (unsigned int)(g * 255.f + 0.5f);
unsigned int bi = (unsigned int)(b * 255.f + 0.5f);
unsigned int ai = (unsigned int)(alpha * 255.f + 0.5f);
return IM_COL32(ri, gi, bi, ai);
}
}
return data_table::parse_hex_color(sorted_sv.back().color, alpha);
}
} // anon namespace
namespace data_table {
// ---------------------------------------------------------------------------
// parse_hex_color: "#rrggbb" / "#rrggbbaa" -> ImU32 with explicit alpha.
// Declared in data_table_color_rules.h (public API — used by data_table.cpp
// cell renderers and by apply_color_rules_for_cell).
// ---------------------------------------------------------------------------
ImU32 parse_hex_color(const std::string& hex, float alpha)
{
const char* p = hex.c_str();
if (*p == '#') ++p;
unsigned int r = 0, g = 0, b = 0, a = 255;
int parsed = std::sscanf(p, "%02x%02x%02x%02x", &r, &g, &b, &a);
if (parsed < 3) return IM_COL32(128, 128, 128, 255);
if (parsed == 3) {
a = (unsigned int)(alpha * 255.f + 0.5f);
}
return IM_COL32(r, g, b, a);
}
// ---------------------------------------------------------------------------
// auto_categorical_color: deterministic palette for a string value.
// Hashes the value to an index in a fixed 12-color palette (Tailwind-ish).
// Same value always maps to the same color. FNV-1a 32-bit.
// ---------------------------------------------------------------------------
ImU32 auto_categorical_color(const char* value, float alpha)
{
static const unsigned int kPalette[] = {
0xFFef4444u, // red-500
0xFFf97316u, // orange-500
0xFFf59e0bu, // amber-500
0xFF84cc16u, // lime-500
0xFF22c55eu, // green-500
0xFF14b8a6u, // teal-500
0xFF06b6d4u, // cyan-500
0xFF3b82f6u, // blue-500
0xFF8b5cf6u, // violet-500
0xFFa855f7u, // purple-500
0xFFec4899u, // pink-500
0xFFd946efu, // fuchsia-500
};
constexpr size_t N = sizeof(kPalette) / sizeof(kPalette[0]);
uint32_t h = 2166136261u;
for (const char* p = value ? value : ""; *p; ++p) {
h ^= (uint8_t)(*p);
h *= 16777619u;
}
unsigned int packed = kPalette[h % N];
unsigned int a = (unsigned int)(alpha * 255.f + 0.5f);
unsigned int rgb = packed & 0x00FFFFFFu;
return rgb | (a << 24);
}
// ---------------------------------------------------------------------------
// resolve_categorical_dot_color: look up dot color from rule's dot_map; fall
// back to auto_categorical_color if no match.
// ---------------------------------------------------------------------------
ImU32 resolve_categorical_dot_color(const ColorRule& cr, const char* value)
{
if (!cr.dot_map.empty()) {
for (const auto& kv : cr.dot_map) {
if (kv.first == (value ? value : ""))
return parse_hex_color(kv.second, cr.dot_alpha);
}
}
return auto_categorical_color(value, cr.dot_alpha);
}
// parse_cell_number: parse a cell string as double. Returns NaN on failure.
// Only needed here (not shared).
static double parse_cell_number(const char* s) {
if (!s || !*s) return std::numeric_limits<double>::quiet_NaN();
char* end = nullptr;
double v = std::strtod(s, &end);
if (end == s) return std::numeric_limits<double>::quiet_NaN();
return v;
}
// ---------------------------------------------------------------------------
// apply_color_rules_for_cell: applies all matching ColorRules for column `c`.
// Must be called AFTER ImGui::TableSetColumnIndex and BEFORE cell renderer.
// ---------------------------------------------------------------------------
void apply_color_rules_for_cell(const State& st,
int c, const char* cell,
ImVec2 cell_min, float cell_w, float cell_h,
float& dot_advance_px)
{
dot_advance_px = 0.f;
ImDrawList* draw = ImGui::GetWindowDrawList();
for (const auto& cr : st.color_rules) {
if (cr.col != c) continue;
switch (cr.kind) {
case ColorRuleKind::CellBg: {
if (cell && cr.equals == cell) {
ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, (ImU32)cr.color);
}
break;
}
case ColorRuleKind::NumericRange: {
double v = parse_cell_number(cell);
if (std::isnan(v)) break;
double span = cr.range_max - cr.range_min;
float t = (span > 1e-12) ? (float)((v - cr.range_min) / span) : 0.5f;
ImU32 tint = lerp_color_along_stops(cr.range_stops, t, cr.range_alpha);
ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, tint);
break;
}
case ColorRuleKind::CategoricalDot: {
ImU32 dot = resolve_categorical_dot_color(cr, cell);
float radius = cr.dot_radius_px > 0.f ? cr.dot_radius_px : 4.0f;
ImVec2 c1(cell_min.x + radius + 2.f,
cell_min.y + cell_h * 0.5f);
draw->AddCircleFilled(c1, radius, dot, 12);
dot_advance_px = radius * 2.f + 6.f;
(void)cell_w;
break;
}
}
}
}
// ---------------------------------------------------------------------------
// draw_color_rule_menu: dibuja el submenu "Conditional color" para la columna
// `col`. Retorna true si el usuario hizo click en "Apply" (la regla ya fue
// anadida a st.color_rules).
//
// editor_st: ColorRuleEditorState de UiState (draft state per columna).
// col_type: tipo efectivo de la columna (para auto-seleccion de modo).
// ---------------------------------------------------------------------------
bool draw_color_rule_menu(State& st, int col, ColumnType col_type,
ColorRuleEditorState& editor_st)
{
bool applied = false;
// Auto-pick sensible default on first open for this column.
if (editor_st.color_rule_kind.find(col) == editor_st.color_rule_kind.end()) {
editor_st.color_rule_kind[col] =
(col_type == ColumnType::Int || col_type == ColumnType::Float) ? 2 : 0;
}
int& kind_i = editor_st.color_rule_kind[col]; // 0=CellBg, 1=CatDot, 2=NumRange
ImGui::TextDisabled("Mode");
ImGui::RadioButton("Cell bg", &kind_i, 0); ImGui::SameLine();
ImGui::RadioButton("Categorical dot", &kind_i, 1); ImGui::SameLine();
ImGui::RadioButton("Numeric range", &kind_i, 2);
ImGui::Separator();
bool apply_clicked = false;
ColorRule draft;
draft.col = col;
if (kind_i == 0) {
// ---- Cell bg ----
auto& vbuf = editor_st.color_value_inputs[col];
vbuf.resize(256, '\0');
if (editor_st.color_picker_vals.find(col) == editor_st.color_picker_vals.end())
editor_st.color_picker_vals[col] = ImVec4(0.85f, 0.40f, 0.30f, 0.60f);
ImVec4& cv = editor_st.color_picker_vals[col];
ImGui::SetNextItemWidth(180);
ImGui::InputText("equals", vbuf.data(), vbuf.size());
ImGui::ColorEdit4("color", &cv.x, ImGuiColorEditFlags_NoInputs);
if (ImGui::Button("Apply##cellbg")) {
draft.kind = ColorRuleKind::CellBg;
draft.equals = std::string(vbuf.c_str());
draft.color = (unsigned int)ImGui::ColorConvertFloat4ToU32(cv);
apply_clicked = true;
}
}
else if (kind_i == 1) {
// ---- Categorical dot ----
auto& d = editor_st.cat_dot_drafts[col];
ImGui::TextWrapped("Dibuja un punto coloreado al inicio de cada celda.");
ImGui::TextWrapped("Color asignado automaticamente por valor (palette de 12).");
ImGui::SetNextItemWidth(120);
ImGui::SliderFloat("alpha", &d.alpha, 0.2f, 1.0f);
ImGui::SetNextItemWidth(120);
ImGui::SliderFloat("radius px", &d.radius_px, 2.0f, 9.0f);
// Preview swatches (8 sample values).
ImGui::TextDisabled("preview:");
ImGui::SameLine();
const char* samples[] = {"A","B","C","D","E","F","G","H"};
for (auto* s : samples) {
ImU32 col_u = auto_categorical_color(s, d.alpha);
ImVec2 p = ImGui::GetCursorScreenPos();
ImGui::GetWindowDrawList()->AddCircleFilled(
ImVec2(p.x + d.radius_px, p.y + ImGui::GetTextLineHeight() * 0.5f),
d.radius_px, col_u, 12);
ImGui::Dummy(ImVec2(d.radius_px * 2.f + 2.f, ImGui::GetTextLineHeight()));
ImGui::SameLine();
}
ImGui::NewLine();
if (ImGui::Button("Apply##catdot")) {
draft.kind = ColorRuleKind::CategoricalDot;
draft.dot_alpha = d.alpha;
draft.dot_radius_px = d.radius_px;
apply_clicked = true;
}
}
else if (kind_i == 2) {
// ---- Numeric range (3-color gradient) ----
auto& d = editor_st.num_range_drafts[col];
ImGui::SetNextItemWidth(110);
ImGui::InputDouble("min", &d.min);
ImGui::SameLine();
ImGui::SetNextItemWidth(110);
ImGui::InputDouble("max", &d.max);
ImGui::SetNextItemWidth(140);
ImGui::SliderFloat("alpha", &d.alpha, 0.05f, 0.9f);
ImGui::SetNextItemWidth(100);
ImGui::InputText("lo color (hex)", d.c_lo, sizeof(d.c_lo));
ImGui::SetNextItemWidth(100);
ImGui::InputText("mid color (hex)", d.c_md, sizeof(d.c_md));
ImGui::SetNextItemWidth(100);
ImGui::InputText("hi color (hex)", d.c_hi, sizeof(d.c_hi));
// Preview gradient bar (32 lerped cells).
std::vector<ColorStop> preview_stops = {
{0.0f, std::string("#") + d.c_lo},
{0.5f, std::string("#") + d.c_md},
{1.0f, std::string("#") + d.c_hi},
};
ImVec2 p0 = ImGui::GetCursorScreenPos();
float W = 256.f, H = 16.f;
for (int i = 0; i < 32; ++i) {
float t = i / 31.f;
ImU32 col_u = lerp_color_along_stops(preview_stops, t, 1.0f);
ImGui::GetWindowDrawList()->AddRectFilled(
ImVec2(p0.x + (W / 32.f) * i, p0.y),
ImVec2(p0.x + (W / 32.f) * (i + 1), p0.y + H),
col_u);
}
ImGui::Dummy(ImVec2(W, H + 4.f));
if (ImGui::Button("Apply##numrange")) {
draft.kind = ColorRuleKind::NumericRange;
draft.range_min = d.min;
draft.range_max = d.max;
draft.range_alpha = d.alpha;
draft.range_stops = preview_stops;
apply_clicked = true;
}
}
if (apply_clicked) {
// Replace any existing rule of same kind on this col (one per kind).
for (size_t i = 0; i < st.color_rules.size();) {
if (st.color_rules[i].col == col &&
st.color_rules[i].kind == draft.kind) {
st.color_rules.erase(st.color_rules.begin() + (int)i);
} else {
++i;
}
}
st.color_rules.push_back(draft);
ImGui::CloseCurrentPopup();
applied = true;
}
ImGui::SameLine();
if (ImGui::Button("Clear col")) {
for (size_t i = 0; i < st.color_rules.size();) {
if (st.color_rules[i].col == col)
st.color_rules.erase(st.color_rules.begin() + (int)i);
else
++i;
}
}
return applied;
}
} // namespace data_table
@@ -0,0 +1,91 @@
#pragma once
// data_table_color_rules — editor de reglas de color por columna + aplicacion.
// Sub-funcion extraida de modules/data_table/data_table.cpp.
// Issue 0107c. Wave de refactor para partir el god-file 4777 LOC.
//
// Responsabilidad: helpers estaticos de color (parse_hex_color, resolve_categorical_dot_color,
// apply_color_rules_for_cell, auto_categorical_color) + UI del submenu "Conditional color"
// dentro del header-menu de columnas.
//
// Dependencias: data_table_types.h (ColorRule, ColorRuleKind, State).
// imgui.h, core/icons_tabler.h.
// NO depende de UiState global — recibe el draft state que necesita por referencia.
#include "core/data_table_types.h"
#include "imgui.h"
#include <string>
#include <unordered_map>
namespace data_table {
// ---------------------------------------------------------------------------
// Helpers de color (pure/static, no ImGui state).
// ---------------------------------------------------------------------------
// auto_categorical_color: palette determinista de 12 colores para un valor
// string. Mismo valor -> mismo color siempre. v1.5.0.
ImU32 auto_categorical_color(const char* value, float alpha = 1.0f);
// resolve_categorical_dot_color: busca en dot_map de la regla; fallback a
// auto_categorical_color si no hay match.
ImU32 resolve_categorical_dot_color(const ColorRule& cr, const char* value);
// parse_hex_color: "#rrggbb" / "#rrggbbaa" -> ImU32 con alpha explicitamente.
// Fallback a gris IM_COL32(128,128,128,255) en error.
ImU32 parse_hex_color(const std::string& hex, float alpha = 1.0f);
// apply_color_rules_for_cell: aplica todas las ColorRules para la columna `c`
// ANTES del renderer de celda. Llama ImGui::TableSetBgColor para CellBg,
// dibuja dots para CategoricalDot, y pinta overlay de rango para NumericRange.
// `dot_advance_px` (out): pixeles a avanzar en x antes de texto (por dot).
// Debe llamarse justo despues de ImGui::TableSetColumnIndex, antes del renderer.
void apply_color_rules_for_cell(const State& st,
int c, const char* cell,
ImVec2 cell_min, float cell_w, float cell_h,
float& dot_advance_px);
// ---------------------------------------------------------------------------
// UI del editor de reglas de color (submenu dentro del header-menu de cols).
// Llamada desde draw_header_menu cuando el usuario abre "Conditional color".
// Mutates `st.color_rules` al hacer click en "Apply".
// ---------------------------------------------------------------------------
// ColorRuleEditorState: estado de draft del editor de reglas de color.
// Un ColorRuleEditorState vive en UiState (singleton por instancia de tabla).
// Guarda los drafts per-column en maps indexados por columna.
struct ColorRuleEditorState {
// 0=CellBg, 1=CategoricalDot, 2=NumericRange — per column.
std::unordered_map<int, int> color_rule_kind;
// CellBg draft per col.
std::unordered_map<int, std::string> color_value_inputs;
std::unordered_map<int, ImVec4> color_picker_vals;
// NumericRange draft per col.
struct NumRangeDraft {
double min = 0.0;
double max = 100.0;
float alpha = 0.25f;
char c_lo[8] = "22c55e"; // green-500
char c_md[8] = "f59e0b"; // amber-500
char c_hi[8] = "ef4444"; // red-500
};
std::unordered_map<int, NumRangeDraft> num_range_drafts;
// CategoricalDot draft per col.
struct CatDotDraft {
float alpha = 1.0f;
float radius_px = 4.0f;
};
std::unordered_map<int, CatDotDraft> cat_dot_drafts;
};
// draw_color_rule_menu: dibuja el submenu "Conditional color" para la columna
// `col`. Retorna true si el usuario hizo click en "Apply" (la regla ya fue
// anadida a st.color_rules por la funcion).
// editor_st: draft state per tabla (ColorRuleEditorState de UiState).
// col_type: tipo efectivo de la columna (para auto-seleccion de modo).
bool draw_color_rule_menu(State& st, int col, ColumnType col_type,
ColorRuleEditorState& editor_st);
} // namespace data_table
@@ -0,0 +1,95 @@
---
name: data_table_color_rules
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::apply_color_rules_for_cell(const State& st, int col, const char* cell, ImVec2 cell_min, float cell_w, float cell_h, float& dot_advance_px)"
description: "Helpers de color (parse_hex_color, auto_categorical_color, resolve_categorical_dot_color, apply_color_rules_for_cell) + editor UI del submenu Conditional color para columnas de tabla. Soporta tres modos: CellBg (igualdad exacta), CategoricalDot (punto coloreado por valor via palette determinista de 12 colores), NumericRange (degradado lo/mid/hi por rango). Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, color-rules, tql, cpp-tables]
uses_functions:
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_color_rules.cpp"
framework: imgui
params:
- name: st
desc: "State del data_table que contiene st.color_rules (vector de ColorRule). Solo lectura en apply_color_rules_for_cell; mutado por draw_color_rule_menu al anadir reglas."
- name: col
desc: "Indice de columna (0-based en el output visible del stage activo)."
- name: cell
desc: "Valor de la celda como C-string nullable. Null se trata como cadena vacia."
- name: cell_min
desc: "Esquina top-left de la celda en coordenadas de pantalla (ImGui::GetItemRectMin o equivalente). Usado para posicionar dots CategoricalDot."
- name: cell_w / cell_h
desc: "Anchura y altura de la celda en pixeles. Usados para centrar el dot verticalmente y calcular la zona de fondo NumericRange."
- name: dot_advance_px
desc: "Salida: pixeles a avanzar en X antes de renderizar texto (reserva el espacio del dot). 0 si no hay regla CategoricalDot activa para esta columna."
output: "Void para apply_color_rules_for_cell (efectos: TableSetBgColor + draw dot). Bool para draw_color_rule_menu (true = regla anadida a st.color_rules)."
---
## Documentacion
Sub-funcion que encapsula toda la logica de color condicional por celda de la tabla TQL. Se extrae de `modules/data_table/data_table.cpp` como parte del issue 0107c (partir el god-file 4777 LOC).
### Funciones publicas
| Funcion | Uso |
|---|---|
| `parse_hex_color(hex, alpha)` | Convierte `"#rrggbb"` / `"#rrggbbaa"` a `ImU32`. Fallback gris en error. |
| `auto_categorical_color(value, alpha)` | Palette determinista 12 colores via FNV-1a hash. Mismo valor → mismo color siempre. |
| `resolve_categorical_dot_color(cr, value)` | Busca en `cr.dot_map`; fallback a `auto_categorical_color`. |
| `apply_color_rules_for_cell(st, col, cell, ...)` | Aplica todas las reglas activas para `col`. Llamar ANTES del renderer de celda, despues de `TableSetColumnIndex`. |
| `draw_color_rule_menu(st, col, col_type, editor_st)` | UI del submenu "Conditional color" (dentro del header-menu). Mutates `st.color_rules` en Apply. |
### Modo CellBg
Igualdad exacta: si `cell == rule.equals` pinta el fondo con `rule.color`.
### Modo CategoricalDot
Dibuja un circulo coloreado a la izquierda de cada celda. Color determinado por `resolve_categorical_dot_color`. `dot_advance_px` retorna el offset para que el texto no solape el punto.
### Modo NumericRange
Parsea la celda como double. Interpola entre tres colores (lo/mid/hi) segun el valor relativo en `[rule.range_min, rule.range_max]`. Pinta fondo con `TableSetBgColor`.
## Ejemplo
```cpp
// En el render del grid, por cada celda antes del renderer:
float dot_px = 0.f;
data_table::apply_color_rules_for_cell(st, col, cell_value,
ImGui::GetItemRectMin(), col_width, row_height, dot_px);
if (dot_px > 0.f)
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + dot_px);
// ... renderer normal ...
// Para anadir una regla desde el header-menu:
data_table::ColorRuleEditorState editor;
if (ImGui::BeginMenu("Conditional color")) {
data_table::draw_color_rule_menu(st, col, col_type, editor);
ImGui::EndMenu();
}
```
## Cuando usarla
Usarla cuando renderices celdas individuales de la tabla TQL (llamar desde `data_table_grid`) y cuando muestres el menu de cabecera de columna que permita al usuario definir reglas de color. NO llamarla fuera del contexto de un `ImGui::BeginTable / EndTable` activo.
## Gotchas
- `apply_color_rules_for_cell` DEBE llamarse despues de `ImGui::TableSetColumnIndex` y ANTES del renderer de celda. Si se invierte el orden, `TableSetBgColor` no afecta a la celda correcta.
- El dot CategoricalDot requiere que el draw list de la ventana este activo (`ImGui::GetWindowDrawList()`). No llamar desde un contexto de renderizado diferido.
- `parse_hex_color` acepta strings sin `#` pero si el string tiene caracteres no hex devuelve gris fallback — no paniquea.
- `auto_categorical_color` usa FNV-1a 32-bit: con >12 valores distintos habra colisiones (intencional, visualmente aceptable).
- En la extraccion desde data_table.cpp (tarea 3.8) hay que eliminar la declaracion `static` de `parse_hex_color` y `auto_categorical_color` en el .cpp original y hacer forward-include a este header.
+204
View File
@@ -0,0 +1,204 @@
// data_table_drill — drill-down stack + breadcrumb de stages.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Rangos de lineas del fuente original:
// - make_drill_filter : lineas 699-706
// - apply_drill_step : lineas 708-718
// - undo_drill_step : lineas 720-730
// - drill_up : lineas 732-737
// - draw_stage_breadcrumb : lineas 1386-1483
// - drill_into : lineas 2898-2919
#include "viz/data_table_drill.h"
#include "core/data_table_types.h"
#include "imgui.h"
#include <cstdio>
#include <string>
#include <vector>
namespace data_table {
// ---------------------------------------------------------------------------
// make_drill_filter: crea un Filter Op::Eq para col_idx con el valor dado.
// ---------------------------------------------------------------------------
Filter make_drill_filter(int col_idx, const std::string& value) {
Filter f;
f.col = col_idx;
f.op = Op::Eq;
f.value = value;
return f;
}
// ---------------------------------------------------------------------------
// apply_drill_step: inserta step.added en el stage en step.target_stage y
// actualiza st.active_stage. Retorna true si el step se aplico correctamente.
// ---------------------------------------------------------------------------
bool apply_drill_step(State& st, const DrillStep& step) {
if (step.target_stage < 0 || step.target_stage >= (int)st.stages.size()) return false;
Stage& s = st.stages[step.target_stage];
int pos = step.filter_pos;
if (pos < 0 || pos > (int)s.filters.size()) return false;
s.filters.insert(s.filters.begin() + pos, step.added);
st.active_stage = step.target_stage;
return true;
}
// ---------------------------------------------------------------------------
// undo_drill_step: elimina el filter insertado por apply_drill_step y restaura
// st.active_stage a step.prev_active_stage. Retorna true si se deshizo.
// ---------------------------------------------------------------------------
bool undo_drill_step(State& st, const DrillStep& step) {
if (step.target_stage < 0 || step.target_stage >= (int)st.stages.size()) return false;
Stage& s = st.stages[step.target_stage];
int pos = step.filter_pos;
if (pos < 0 || pos >= (int)s.filters.size()) return false;
s.filters.erase(s.filters.begin() + pos);
if (step.prev_active_stage >= 0 && step.prev_active_stage < (int)st.stages.size())
st.active_stage = step.prev_active_stage;
return true;
}
// ---------------------------------------------------------------------------
// drill_up: retrocede st.active_stage en 1 si hay stages previos.
// Retorna true si se pudo retroceder.
// ---------------------------------------------------------------------------
bool drill_up(State& st) {
if (st.stages.empty()) return false;
if (st.active_stage <= 0) return false;
st.active_stage -= 1;
return true;
}
// ---------------------------------------------------------------------------
// draw_stage_breadcrumb: barra de navegacion de drill con botones < > ^ y
// selector de stages. Mutates st.drill_back/forward y st.active_stage.
// ---------------------------------------------------------------------------
void draw_stage_breadcrumb(State& st) {
st.ensure_stage0();
// Drill history back/forward (fase 10). Botones al inicio.
{
bool can_back = !st.drill_back.empty();
ImGui::BeginDisabled(!can_back);
if (ImGui::SmallButton("<##drill_back")) {
DrillStep s = st.drill_back.back();
st.drill_back.pop_back();
if (undo_drill_step(st, s)) {
st.drill_forward.push_back(s);
}
}
ImGui::EndDisabled();
if (can_back && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill back (%zu)", st.drill_back.size());
ImGui::SameLine();
bool can_fwd = !st.drill_forward.empty();
ImGui::BeginDisabled(!can_fwd);
if (ImGui::SmallButton(">##drill_fwd")) {
DrillStep s = st.drill_forward.back();
st.drill_forward.pop_back();
if (apply_drill_step(st, s)) {
st.drill_back.push_back(s);
}
}
ImGui::EndDisabled();
if (can_fwd && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill forward (%zu)", st.drill_forward.size());
ImGui::SameLine();
bool can_up = (st.active_stage > 0);
ImGui::BeginDisabled(!can_up);
if (ImGui::SmallButton("^##drill_up")) drill_up(st);
ImGui::EndDisabled();
if (can_up && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill up (stage previo, sin perder filters)");
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
}
for (int si = 0; si < (int)st.stages.size(); ++si) {
if (si > 0) { ImGui::SameLine(); ImGui::TextDisabled(">"); ImGui::SameLine(); }
bool active = (si == st.active_stage);
if (active) {
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 80, 140, 200, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(100, 160, 220, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 60, 120, 180, 240));
} else {
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 70, 70, 90, 200));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 90, 90, 120, 220));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 55, 55, 75, 220));
}
char label[256];
if (si == 0) {
std::snprintf(label, sizeof(label), "Raw##stage%d", si);
} else {
const Stage& s = st.stages[si];
std::string desc;
for (size_t i = 0; i < s.breakouts.size() && i < 2; ++i) {
if (i > 0) desc += ", ";
desc += s.breakouts[i];
}
if (s.breakouts.size() > 2) desc += "...";
if (desc.empty())
std::snprintf(label, sizeof(label), "Stage %d##s%d", si, si);
else
std::snprintf(label, sizeof(label), "Stage %d: by %s##s%d",
si, desc.c_str(), si);
}
if (ImGui::Button(label)) st.active_stage = si;
ImGui::PopStyleColor(3);
if (si > 0) {
ImGui::SameLine();
char xlbl[32];
std::snprintf(xlbl, sizeof(xlbl), "x##rm_s%d", si);
if (ImGui::SmallButton(xlbl)) {
// borra ese stage y sucesores
while ((int)st.stages.size() > si) st.stages.pop_back();
if (st.active_stage >= (int)st.stages.size())
st.active_stage = (int)st.stages.size() - 1;
if (st.active_stage < 0) st.active_stage = 0;
break;
}
}
}
ImGui::SameLine();
ImGui::TextDisabled(">");
ImGui::SameLine();
if (ImGui::SmallButton("+ Stage##add_stage")) {
st.stages.push_back(Stage{});
st.active_stage = (int)st.stages.size() - 1;
}
}
// ---------------------------------------------------------------------------
// drill_into: API publica. Anade un filter Op::Eq sobre col_name=value al
// stage (from_stage - 1) y cambia st.active_stage a ese stage previo.
// Graba el step en st.drill_back y limpia st.drill_forward (rama nueva).
// ---------------------------------------------------------------------------
void drill_into(State& st, int from_stage,
const std::string& col_name, const std::string& value,
const std::vector<std::string>& prev_input_headers)
{
if (from_stage <= 0 || from_stage >= (int)st.stages.size()) return;
int target = from_stage - 1;
int ci = -1;
for (size_t i = 0; i < prev_input_headers.size(); ++i) {
if (prev_input_headers[i] == col_name) { ci = (int)i; break; }
}
if (ci < 0) return;
// Fase 10: graba step en drill_back, limpia forward (rama nueva).
DrillStep step;
step.target_stage = target;
step.filter_pos = (int)st.stages[target].filters.size();
step.prev_active_stage = st.active_stage;
step.added = make_drill_filter(ci, value);
apply_drill_step(st, step);
st.drill_back.push_back(step);
st.drill_forward.clear();
}
} // namespace data_table
+70
View File
@@ -0,0 +1,70 @@
#pragma once
// data_table_drill — drill-down stack + breadcrumb de stages.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// - make_drill_filter: construye un Filter Op::Eq sobre un col_idx.
// - apply_drill_step / undo_drill_step: manipula el stack drill_back/forward.
// - drill_up: retrocede un stage.
// - drill_into: API publica que anade un filter al stage previo y cambia active.
// - draw_stage_breadcrumb: UI de la barra de breadcrumb con botones < > ^.
//
// Rangos del fuente original:
// - make_drill_filter : linea 700-706
// - apply_drill_step : lineas 708-718
// - undo_drill_step : lineas 720-730
// - drill_up : lineas 731-745
// - drill_into : lineas 2898-2919
// - draw_stage_breadcrumb: lineas 1383-1488
//
// Dependencias: data_table_types.h (State, DrillStep, Filter, Stage).
#include "core/data_table_types.h"
#include <string>
#include <vector>
namespace data_table {
// ---------------------------------------------------------------------------
// Helpers internos del drill stack (usados por drill_into y draw_stage_breadcrumb).
// Se exponen en el header para que data_table_grid pueda llamarlos al
// procesar el drill popup de celdas en stage > 0.
// ---------------------------------------------------------------------------
// make_drill_filter: crea un Filter Op::Eq para col_idx con el valor dado.
Filter make_drill_filter(int col_idx, const std::string& value);
// apply_drill_step: inserta step.added en el stage en step.target_stage y
// actualiza st.active_stage. Retorna true si el step se aplico correctamente.
bool apply_drill_step(State& st, const DrillStep& step);
// undo_drill_step: elimina el filter insertado por apply_drill_step y restaura
// st.active_stage a step.prev_active_stage. Retorna true si se deshizo.
bool undo_drill_step(State& st, const DrillStep& step);
// drill_up: retrocede st.active_stage en 1 si hay stages previos.
// Retorna true si se pudo retroceder.
bool drill_up(State& st);
// ---------------------------------------------------------------------------
// API publica: drill_into
// Anade un filter Op::Eq sobre col_name=value al stage (from_stage - 1) y
// cambia st.active_stage a from_stage - 1. Graba el step en st.drill_back
// y limpia st.drill_forward (rama nueva del arbol de drill).
//
// prev_input_headers: headers del INPUT del stage from_stage (para traducir
// col_name a col_idx en el stage previo).
// ---------------------------------------------------------------------------
void drill_into(State& st, int from_stage,
const std::string& col_name, const std::string& value,
const std::vector<std::string>& prev_input_headers);
// ---------------------------------------------------------------------------
// UI: draw_stage_breadcrumb
// Dibuja la barra de navegacion de drill con botones < (back), > (forward),
// ^ (up) y el nombre del stage activo. Mutates st.drill_back/forward y
// st.active_stage en respuesta a clicks.
// ---------------------------------------------------------------------------
void draw_stage_breadcrumb(State& st);
} // namespace data_table
+85
View File
@@ -0,0 +1,85 @@
---
name: data_table_drill
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::drill_into(State& st, int from_stage, const std::string& col_name, const std::string& value, const std::vector<std::string>& prev_input_headers)"
description: "Drill-down stack + UI breadcrumb para la tabla TQL. Gestiona el arbol de navegacion de stages: drill_into anade un filtro Op::Eq al stage previo y avanza al nivel de detalle, draw_stage_breadcrumb dibuja los botones de navegacion (< back, > forward, ^ up) y el selector de stages activo. Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, drill-down, navigation, tql, cpp-tables]
uses_functions:
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_drill.cpp"
framework: imgui
params:
- name: st
desc: "State mutable que contiene st.stages, st.active_stage, st.drill_back (undo stack), st.drill_forward (redo stack). Todos son mutados por las funciones de este modulo."
- name: from_stage
desc: "Stage desde el que se origina el drill (stage activo al hacer right-click en una celda de breakout). Debe ser > 0 para que haya stage previo donde anadir el filtro."
- name: col_name
desc: "Nombre de la columna breakout sobre la que se drilla (debe existir en prev_input_headers)."
- name: value
desc: "Valor de la celda seleccionada. Se aplica como Filter Op::Eq en el stage from_stage-1."
- name: prev_input_headers
desc: "Headers del INPUT del stage from_stage (= output del stage from_stage-1). Necesarios para traducir col_name a col_idx."
output: "Void. Mutates st.stages[target].filters, st.active_stage, st.drill_back, st.drill_forward."
---
## Documentacion
Sub-funcion que encapsula la logica de drill-down de la tabla TQL. El drill-down permite al usuario hacer right-click en una celda de una columna breakout (stage > 0) y "entrar" al detalle del grupo seleccionado anadiendo un filtro al stage previo.
### Funciones publicas
| Funcion | Uso |
|---|---|
| `make_drill_filter(col_idx, value)` | Helper: crea `Filter{col_idx, Op::Eq, value}`. |
| `apply_drill_step(st, step)` | Inserta `step.added` en `st.stages[step.target_stage].filters` y actualiza `st.active_stage`. |
| `undo_drill_step(st, step)` | Invierte `apply_drill_step`: elimina el filtro y restaura `st.active_stage`. |
| `drill_up(st)` | Decrementa `st.active_stage` en 1 (sin crear entry en el undo stack). |
| `drill_into(st, from, col, val, hdrs)` | API publica: compone make_drill_filter + apply_drill_step + graba en drill_back + limpia drill_forward. |
| `draw_stage_breadcrumb(st)` | UI: botones < > ^ + combo de stages. |
### Invariante del stack
- `st.drill_back`: historial de drill steps (undo). Cada `drill_into` agrega al final.
- `st.drill_forward`: pasos deshecho (redo). Se limpia en cada `drill_into` nueva.
- `drill_up` NO agrega al stack — es un atajo que no se puede rehacer.
## Ejemplo
```cpp
// En el cell popup de una celda de breakout col (stage > 0):
if (ImGui::MenuItem(lbl)) {
data_table::drill_into(st, active_stage,
cur_headers[col], cell_value,
input_headers_active);
ImGui::CloseCurrentPopup();
}
// Breadcrumb al inicio del area de chrome:
if (chrome_visible) {
data_table::draw_stage_breadcrumb(st);
}
```
## Cuando usarla
`drill_into` se llama desde el popup de celda en `data_table_grid_cpp_viz` cuando el usuario hace right-click en una columna breakout. `draw_stage_breadcrumb` se llama una vez por frame en el area de chrome antes de los chips.
## Gotchas
- `drill_into` requiere `from_stage > 0`. Si `from_stage <= 0` la funcion retorna sin hacer nada (no hay stage previo).
- `col_name` debe existir en `prev_input_headers`; si no se encuentra, la funcion retorna sin efecto (el filter no se anade).
- `apply_drill_step` y `undo_drill_step` modifican `st.stages[step.target_stage].filters` por posicion (`filter_pos`). Hay un riesgo de corrupcion si los filtros del stage cambian entre apply y undo por otra via. El design actual asume que el caller no muta los filtros del stage target fuera de este API.
- `draw_stage_breadcrumb` dibuja los botones en linea horizontal; si el area es muy estrecha (< 120px) los botones solapan. El caller debe asegurar suficiente ancho o usar `ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ...)`.
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
#pragma once
// data_table_grid — render del grid de datos: ImGui::BeginTable, headers con sort,
// freeze cols, drag-drop de columnas, renderers declarativos (Badge/Progress/
// Duration/Icon/Button/Dots/CategoricalChip/ColorScale), color rules per cell,
// selection (Ctrl+C TSV), stats overlay, cell popup.
//
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
// Rangos del fuente original:
// - draw_cell_custom (declarative renderers): lineas 376-655
// - Grid setup + header row + cell loop (stage 0): lineas 3436-3765
// - Grid setup + cell loop (stage > 0): lineas 4040-4311
// - Ctrl+C TSV copy: lineas 3725-3766
// - Stats overlay en encabezados: lineas 4160-4226
//
// Dependencias: data_table_types.h, data_table_color_rules.h, imgui.h.
#include "core/data_table_types.h"
#include "imgui.h"
#include <vector>
#include <string>
namespace data_table {
// ---------------------------------------------------------------------------
// draw_cell_custom — renderer declarativo por ColumnSpec.renderer.
// Soporta: Text (fallback), Badge, Progress, Duration, Icon, Button, Dots,
// CategoricalChip, ColorScale.
// Emite TableEvent (ButtonClick, RowDoubleClick) en events_out si no-null.
// Llamar dentro del contexto de un ImGui::BeginTable activo.
// ---------------------------------------------------------------------------
void draw_cell_custom(const ColumnSpec& spec, const char* value,
int row, int col,
std::vector<TableEvent>* events_out);
// ---------------------------------------------------------------------------
// render_grid_stage0 — renderiza el grid para el path stage==0 (datos crudos
// + derivados via Lua). Incluye: BeginTable, headers (sort click, drag-drop,
// header menu via draw_header_menu), cell loop con draw_cell_custom,
// apply_color_rules_for_cell, selection rect, Ctrl+C TSV copy.
//
// Parametros:
// id — ID ImGui unico para el BeginTable.
// st — State mutable (sort, selection, color_rules, col_order, ...).
// cells — cells originales row-major [rows * orig_cols].
// row_count — numero de filas originales.
// orig_cols — numero de columnas originales (sin derived).
// eff_cols — numero de columnas efectivas (orig + derived).
// eff_headers — punteros a los nombres de columna efectivos (size eff_cols).
// eff_types — tipos de columna efectivos (size eff_cols).
// src_for_eff — mapeo eff col -> src col en orig (size eff_cols).
// visible_rows — filas visibles tras filtrado (indices en [0, row_count)).
// events_out — si no null, recibe TableEvent de esta frame.
// ---------------------------------------------------------------------------
void render_grid_stage0(const char* id,
State& st,
const char* const* cells,
int row_count, int orig_cols, int eff_cols,
const char* const* eff_headers,
const ColumnType* eff_types,
const int* src_for_eff,
const std::vector<int>& visible_rows,
const TableInput& main_t,
std::vector<TableEvent>* events_out);
// ---------------------------------------------------------------------------
// render_grid_stage_n — renderiza el grid para el path stage > 0 (datos
// materializados del compute_stage chain). Similar a stage0 pero opera sobre
// cur_cells/cur_rows/cur_cols_n ya materializados.
//
// input_headers_active — headers del INPUT del stage activo (para drill popup).
// n_breakouts — numero de breakout cols en el stage activo.
// ---------------------------------------------------------------------------
void render_grid_stage_n(const char* id,
State& st,
const char* const* cur_cells,
int cur_rows, int cur_cols_n,
const std::vector<std::string>& cur_headers,
const std::vector<ColumnType>& cur_types,
const std::vector<std::string>& input_headers_active,
int n_breakouts,
const TableInput& main_t,
std::vector<TableEvent>* events_out);
} // namespace data_table
+103
View File
@@ -0,0 +1,103 @@
---
name: data_table_grid
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::render_grid_stage0(const char* id, State& st, const char* const* cells, int row_count, int orig_cols, int eff_cols, const char* const* eff_headers, const ColumnType* eff_types, const int* src_for_eff, const std::vector<int>& visible_rows, const TableInput& main_t, std::vector<TableEvent>* events_out)"
description: "Render del grid de datos de la tabla TQL: ImGui::BeginTable con freeze de cabecera, headers clicables (sort), drag-drop de columnas para reordenar, renderers declarativos por ColumnSpec (Badge/Progress/Duration/Icon/Button/Dots/CategoricalChip/ColorScale), aplicacion de ColorRules por celda, seleccion de rango (Ctrl+C TSV), stats overlay en encabezados. Dos entrypoints: render_grid_stage0 (datos crudos + derived Lua) y render_grid_stage_n (output materializado de compute_stage). Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, grid, cell-renderer, sorting, tql, cpp-tables]
uses_functions:
- data_table_color_rules_cpp_viz
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_grid.cpp"
framework: imgui
params:
- name: id
desc: "ID unico de ImGui para BeginTable (ej. '##my_table'). Debe ser unico por instancia en la ventana."
- name: st
desc: "State mutable. El grid lee st.col_order, st.col_visible, st.color_rules, st.sel_anchor/end, st.stages[active].sorts. Lo muta en respuesta a clicks, drag-drop, seleccion."
- name: cells
desc: "Puntero a cells originales row-major [rows * orig_cols]. Para stage0; stage_n recibe cur_cells ya materializados."
- name: row_count / orig_cols / eff_cols
desc: "Dimensiones de la tabla. orig_cols = columnas del input; eff_cols = orig_cols + derived cols del stage 0."
- name: eff_headers / eff_types
desc: "Headers y tipos efectivos (size eff_cols). Usados para headers del BeginTable y para render de celda."
- name: src_for_eff
desc: "Mapeo eff_col_idx -> src_col_idx en la tabla original. Necesario para leer cells[row * orig_cols + src]. Solo para stage0."
- name: visible_rows
desc: "Indices de filas visibles tras filtrado (output de compute_visible_rows). Solo para stage0."
- name: main_t
desc: "TableInput principal: proporciona column_specs para draw_cell_custom y los column_spec ids para eventos."
- name: events_out
desc: "Si no null, recibe TableEvent (ButtonClick, RowDoubleClick, RowRightClick) generados este frame."
output: "Void. Mutates st (sort, selection, col_order). Appends to events_out si no null."
---
## Documentacion
Sub-funcion que encapsula el render del grid de celdas de la tabla TQL. Es el bloque mas grande del refactor 0107c (~1300 LOC del fuente original).
### Renderers declarativos (draw_cell_custom)
| Renderer | Que muestra |
|---|---|
| `Text` | Selectable de texto, tooltip on-hover si `tooltip_on_hover=true` |
| `Badge` | Pastilla de color (background del spec) con texto |
| `Progress` | Barra de progreso ImGui [0..100] |
| `Duration` | Formato "Xh Ym Zs" desde segundos enteros |
| `Icon` | Glyph Tabler (usa `icons_tabler.h`) |
| `Button` | Boton clickable; emite `TableEventKind::ButtonClick` con `action_id` |
| `Dots` | Circulos coloreados por reglas de chips del ColumnSpec |
| `CategoricalChip` | Pastilla con borde coloreado; color por palette hash |
| `ColorScale` | Degradado de fondo segun valor numerico normalizado |
### Flujo render_grid_stage0
1. Setup `ImGuiTableFlags` (Borders + RowBg + Resizable + ScrollY).
2. `BeginTable``TableSetupColumn` por cada col visible en `st.col_order`.
3. `TableSetupScrollFreeze(0, 1)` para freeze de header.
4. Header row: `Selectable` con sort-click (`apply_header_sort_click`), drag-drop source/target para reordenar columnas.
5. Por cada fila en `visible_rows`: por cada col visible en `st.col_order`: `apply_color_rules_for_cell``draw_cell_custom`.
6. Seleccion de rango: mouse drag con `sel_anchor/sel_end`; Ctrl+C genera TSV al portapapeles.
7. `EndTable` + `PopStyleColor(3)`.
### Flujo render_grid_stage_n
Igual pero opera sobre `cur_cells` ya materializados del chain `compute_stage`. Ademas:
- Stats overlay en headers (histograma/top-categories de `st.stats_cache`).
- Drill popup: right-click en celda de breakout col ofrece "Drill into: col = val".
## Ejemplo
```cpp
// Dentro del render() principal, path stage == 0:
if (st.display == data_table::ViewMode::Table && visible_cols > 0) {
data_table::render_grid_stage0(id, st,
cells_in, row_count_in, orig_cols, eff_cols,
eff_headers.data(), eff_types.data(), src_for_eff.data(),
visible_rows, main_t, events_out);
}
```
## Cuando usarla
Llamar desde el entrypoint thin `data_table::render()` tras calcular `visible_rows` (stage 0) o materializar el chain (stage > 0). No llamar directamente desde apps — la API publica es `data_table::render()`.
## Gotchas
- `render_grid_stage0` y `render_grid_stage_n` deben llamarse dentro de un `ImGui::BeginChild` o contexto de ventana activo. No funcionan fuera de un frame ImGui.
- La funcion hace `PushStyleColor` para Header/HeaderHovered/HeaderActive y debe hacer el correspondiente `PopStyleColor(3)`. No anidar pushes adicionales sin su pop.
- `draw_cell_custom` con renderer `Button` emite eventos; si `events_out` es null se ignoran silenciosamente (back-compat).
- El `thread_local` en el entrypoint original (main_hdr_ptrs, joinables_v) no se mueve a esta funcion — esos son responsabilidad del entrypoint thin.
- Despues del split, `apply_color_rules_for_cell` vive en `data_table_color_rules_cpp_viz` — no re-implementar aqui.
+429
View File
@@ -0,0 +1,429 @@
// data_table_viz_panels — paneles de visualizacion lateral de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Funciones implementadas:
// - draw_table_toggle (ex lineas 745-767)
// - draw_extra_panel (ex lineas 771-856)
// - draw_viz_config_popup (ex lineas 858-1021)
// - draw_viz_selector (ex lineas 1034-1110)
// - maybe_recompute_stats (ex lineas 1118-1145)
#include "viz/data_table_viz_panels.h"
#include "data_table/data_table_internal.h"
#include "viz/data_table_grid.h" // draw_cell_custom
#include "core/data_table_types.h"
#include "core/compute_column_stats.h"
#include "core/tql_emit.h"
#include "viz/viz_render.h"
#include "imgui.h"
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
namespace data_table {
// ---------------------------------------------------------------------------
// draw_table_toggle
// ---------------------------------------------------------------------------
void draw_table_toggle(ViewMode& display, ViewMode& last_non_table,
const char* id_suffix,
State* st_opt)
{
bool is_table = (display == ViewMode::Table);
char b[64];
std::snprintf(b, sizeof(b), "%s##tbl_%s",
is_table ? "Show chart" : "Show table", id_suffix);
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(80, 140, 200, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(100, 160, 220, 240));
if (ImGui::SmallButton(b)) {
if (is_table) {
ViewMode tgt = (last_non_table == ViewMode::Table)
? ViewMode::Bar : last_non_table;
display = tgt;
if (st_opt && view_mode_needs_aggregation(tgt)) {
auto_promote_aggregated(*st_opt);
}
} else {
last_non_table = display;
display = ViewMode::Table;
}
}
ImGui::PopStyleColor(2);
}
// ---------------------------------------------------------------------------
// draw_extra_panel
// ---------------------------------------------------------------------------
bool draw_extra_panel(State& st, VizPanel& p, int idx,
const StageOutput& so,
const std::vector<ColumnSpec>* col_specs)
{
bool close_req = false;
char child_id[64]; std::snprintf(child_id, sizeof(child_id), "##extra_viz_%d", idx);
ImGui::BeginChild(child_id, ImVec2(0, 320), true);
// Toolbar
int n_modes = 0;
const ViewMode* modes = all_view_modes(&n_modes);
ImGui::TextDisabled("View:");
ImGui::SameLine();
ImGui::SetNextItemWidth(180);
char combo_id[64]; std::snprintf(combo_id, sizeof(combo_id), "##ev_mode_%d", idx);
if (ImGui::BeginCombo(combo_id, view_mode_label(p.display))) {
for (int i = 0; i < n_modes; ++i) {
bool sel = (modes[i] == p.display);
if (ImGui::Selectable(view_mode_label(modes[i]), sel)) {
p.display = modes[i];
p.config.fit_request = true;
}
if (sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::SameLine();
char fit_id[32]; std::snprintf(fit_id, sizeof(fit_id), "Fit##ev_fit_%d", idx);
if (ImGui::SmallButton(fit_id)) p.config.fit_request = true;
ImGui::SameLine();
char lock_id[32]; std::snprintf(lock_id, sizeof(lock_id), "%s##ev_lock_%d",
p.config.locked ? "Locked" : "Lock", idx);
if (p.config.locked) {
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(180, 60, 60, 230));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(200, 80, 80, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(150, 40, 40, 240));
}
if (ImGui::SmallButton(lock_id)) p.config.locked = !p.config.locked;
if (p.config.locked) ImGui::PopStyleColor(3);
ImGui::SameLine();
char close_id[32]; std::snprintf(close_id, sizeof(close_id), "X##ev_close_%d", idx);
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(120, 50, 50, 220));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(160, 70, 70, 240));
if (ImGui::SmallButton(close_id)) close_req = true;
ImGui::PopStyleColor(2);
// Toggle Table <-> View per-panel
char ts[32]; std::snprintf(ts, sizeof(ts), "ep%d", idx);
draw_table_toggle(p.display, p.last_non_table, ts);
// Render: si Table -> mini table; else chart.
if (p.display == ViewMode::Table) {
ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
ImGuiTableFlags_ScrollX;
char tid[64]; std::snprintf(tid, sizeof(tid), "##ep_table_%d", idx);
if (so.cols > 0 && ImGui::BeginTable(tid, so.cols, flags, ImVec2(0, 0))) {
for (int c = 0; c < so.cols; ++c)
ImGui::TableSetupColumn(so.headers[c].c_str());
ImGui::TableHeadersRow();
for (int r = 0; r < so.rows; ++r) {
ImGui::TableNextRow();
for (int c = 0; c < so.cols; ++c) {
ImGui::TableSetColumnIndex(c);
const char* s = so.cells[(size_t)r * so.cols + c];
// Issue 0081-N: declarative renderer for extra panel mini-table.
// events_out not propagated to mini-table (secondary render).
bool custom_ep = false;
if (col_specs && c < (int)col_specs->size()) {
const ColumnSpec& cs = (*col_specs)[(size_t)c];
if (cs.renderer != CellRenderer::Text) {
draw_cell_custom(cs, s, r, c, nullptr);
custom_ep = true;
}
}
if (!custom_ep) ImGui::TextUnformatted(s ? s : "");
}
}
ImGui::EndTable();
}
} else {
viz::render(so, p.display, p.config, ImVec2(-1, -1));
}
ImGui::EndChild();
(void)st;
return close_req;
}
// ---------------------------------------------------------------------------
// draw_viz_config_popup
// ---------------------------------------------------------------------------
void draw_viz_config_popup(State& st) {
if (!ImGui::BeginPopup("##viz_cfg_popup")) return;
ImGui::Text("Configure: %s", view_mode_label(st.display));
ImGui::Separator();
auto cols = collect_active_col_info(st);
std::vector<const char*> all_names;
std::vector<const char*> num_names;
std::vector<const char*> cat_names;
for (auto& c : cols) {
all_names.push_back(c.name.c_str());
if (c.type == ColumnType::Int || c.type == ColumnType::Float)
num_names.push_back(c.name.c_str());
else
cat_names.push_back(c.name.c_str());
}
auto& vc = st.viz_config;
ViewMode m = st.display;
auto combo_for_col = [&](const char* label, std::string& target,
const std::vector<const char*>& options) {
const char* preview = target.empty() ? "(auto)" : target.c_str();
ImGui::SetNextItemWidth(220);
if (ImGui::BeginCombo(label, preview)) {
if (ImGui::Selectable("(auto)", target.empty())) target.clear();
for (auto& o : options) {
bool sel = (target == o);
if (ImGui::Selectable(o, sel)) target = o;
}
ImGui::EndCombo();
}
};
// X col: scatter, line, area, stairs, hist2d, bubble
bool needs_x = (m == ViewMode::Scatter || m == ViewMode::Line ||
m == ViewMode::Area || m == ViewMode::Stairs ||
m == ViewMode::Histogram2D || m == ViewMode::Bubble);
if (needs_x) combo_for_col("X column", vc.x_col, num_names);
// Y cols: most modes
bool needs_y = (m != ViewMode::Pie && m != ViewMode::Donut && m != ViewMode::Funnel &&
m != ViewMode::Candlestick);
if (needs_y) {
ImGui::Text("Y columns:");
ImGui::SameLine();
ImGui::TextDisabled("(%d selected; empty = auto)", (int)vc.y_cols.size());
ImGui::Indent();
for (auto& nn : num_names) {
std::string ns = nn;
bool checked = std::find(vc.y_cols.begin(), vc.y_cols.end(), ns) != vc.y_cols.end();
if (ImGui::Checkbox(nn, &checked)) {
if (checked) vc.y_cols.push_back(ns);
else {
auto it = std::find(vc.y_cols.begin(), vc.y_cols.end(), ns);
if (it != vc.y_cols.end()) vc.y_cols.erase(it);
}
}
}
ImGui::Unindent();
if (ImGui::SmallButton("Clear Y##clr_y")) vc.y_cols.clear();
}
// Cat col: bar/pie/funnel/box/waterfall
bool needs_cat = (m == ViewMode::Bar || m == ViewMode::Column ||
m == ViewMode::GroupedBar || m == ViewMode::StackedBar ||
m == ViewMode::Pie || m == ViewMode::Donut ||
m == ViewMode::Funnel || m == ViewMode::BoxPlot ||
m == ViewMode::Waterfall);
if (needs_cat) {
// Si el active stage YA esta agrupado (breakouts != empty), la categoria
// del chart la dicta el breakout. Mostrar todas las cols del INPUT del
// stage (= cols pre-agrupacion). Selecionar otra = reemplaza breakouts[0]
// (re-agrupa).
int as = st.active_stage;
bool grouped = (as >= 0 && as < (int)st.stages.size() &&
!st.stages[as].breakouts.empty());
const auto& U = ui();
if (grouped) {
std::vector<const char*> input_cat_names;
for (size_t i = 0; i < U.input_headers_active.size() &&
i < U.input_types_active.size(); ++i) {
ColumnType t = U.input_types_active[i];
if (t == ColumnType::String || t == ColumnType::Date ||
t == ColumnType::Bool || t == ColumnType::Json) {
input_cat_names.push_back(U.input_headers_active[i].c_str());
}
}
std::string cur_break = st.stages[as].breakouts[0];
const char* preview = cur_break.empty() ? "(none)" : cur_break.c_str();
ImGui::SetNextItemWidth(220);
if (ImGui::BeginCombo("Category (breakout)", preview)) {
for (auto& o : input_cat_names) {
bool sel = (cur_break == o);
if (ImGui::Selectable(o, sel)) {
st.stages[as].breakouts[0] = o;
}
}
ImGui::EndCombo();
}
} else {
combo_for_col("Category", vc.cat_col, cat_names);
}
}
// Size col: bubble
if (m == ViewMode::Bubble) combo_for_col("Size column", vc.size_col, num_names);
// Color
ImGui::Separator();
float col_f[4] = {
((vc.primary_color) & 0xFF) / 255.0f,
((vc.primary_color >> 8) & 0xFF) / 255.0f,
((vc.primary_color >> 16) & 0xFF) / 255.0f,
((vc.primary_color >> 24) & 0xFF) / 255.0f,
};
if (vc.primary_color == 0) { col_f[0]=col_f[1]=col_f[2]=1.0f; col_f[3]=1.0f; }
if (ImGui::ColorEdit4("Primary color", col_f, ImGuiColorEditFlags_AlphaBar)) {
unsigned int r2 = (unsigned int)(col_f[0] * 255);
unsigned int g2 = (unsigned int)(col_f[1] * 255);
unsigned int b2 = (unsigned int)(col_f[2] * 255);
unsigned int a2 = (unsigned int)(col_f[3] * 255);
vc.primary_color = (a2 << 24) | (b2 << 16) | (g2 << 8) | r2;
}
ImGui::SameLine();
if (ImGui::SmallButton("Auto##color")) vc.primary_color = 0;
// Hist bins
if (m == ViewMode::Histogram || m == ViewMode::Histogram2D) {
ImGui::SetNextItemWidth(120);
int bins = vc.hist_bins;
if (ImGui::InputInt("Bins (0=auto)", &bins)) {
if (bins < 0) bins = 0;
vc.hist_bins = bins;
}
}
// Pie radius
if (m == ViewMode::Pie || m == ViewMode::Donut) {
ImGui::SetNextItemWidth(120);
float rad = vc.pie_radius;
if (ImGui::SliderFloat("Radius (0=auto)", &rad, 0.0f, 0.5f, "%.2f")) {
vc.pie_radius = rad;
}
}
// Toggles
ImGui::Separator();
ImGui::Checkbox("Show legend", &vc.show_legend);
if (m == ViewMode::Line || m == ViewMode::Area || m == ViewMode::Stairs) {
ImGui::SameLine();
ImGui::Checkbox("Show markers", &vc.show_markers);
}
ImGui::Separator();
if (ImGui::SmallButton("Reset config")) {
vc = ViewConfig{};
}
ImGui::SameLine();
if (ImGui::SmallButton("Close")) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
// ---------------------------------------------------------------------------
// draw_viz_selector
// ---------------------------------------------------------------------------
void draw_viz_selector(State& st) {
int n_modes = 0;
const ViewMode* modes = all_view_modes(&n_modes);
// Right-align: reserve "View: [combo] [Fit] [Lock] [Config] [Ask AI] [+ Viz]"
const float combo_w = 200.0f;
const float total_w = combo_w + 50.0f + 280.0f;
float right_edge = ImGui::GetWindowContentRegionMax().x;
float target_x = right_edge - total_w;
float min_x = ImGui::GetCursorPosX() + 20.0f; // do not overlap breadcrumb
if (target_x < min_x) target_x = min_x;
ImGui::SameLine();
ImGui::SetCursorPosX(target_x);
ImGui::TextDisabled("View:");
ImGui::SameLine();
ImGui::SetNextItemWidth(combo_w);
if (ImGui::BeginCombo("##viz_mode", view_mode_label(st.display))) {
for (int i = 0; i < n_modes; ++i) {
bool sel = (modes[i] == st.display);
if (ImGui::Selectable(view_mode_label(modes[i]), sel)) {
ViewMode nm = modes[i];
if (nm != st.display) {
st.display = nm;
if (view_mode_needs_aggregation(nm)) {
auto_promote_aggregated(st);
}
}
}
if (sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::SameLine();
if (ImGui::SmallButton("Fit##viz_fit")) {
st.viz_config.fit_request = true;
}
ImGui::SameLine();
bool locked = st.viz_config.locked;
if (locked) {
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(180, 60, 60, 230));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(200, 80, 80, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(150, 40, 40, 240));
}
if (ImGui::SmallButton(locked ? "Locked##viz_lock" : "Lock##viz_lock")) {
st.viz_config.locked = !st.viz_config.locked;
}
if (locked) ImGui::PopStyleColor(3);
ImGui::SameLine();
if (ImGui::SmallButton("Config##viz_cfg")) {
ImGui::OpenPopup("##viz_cfg_popup");
}
ImGui::SameLine();
if (ImGui::SmallButton("Ask AI##ask_open")) {
auto& U2 = ui();
U2.ask_ai.open = true;
U2.ask_ai.busy = false;
U2.ask_ai.error.clear();
U2.ask_ai.status.clear();
U2.ask_ai.response_code.clear();
U2.ask_ai.response_raw.clear();
U2.ask_ai.current_tql = tql::emit(st,
std::vector<std::string>(),
std::vector<ColumnType>());
}
ImGui::SameLine();
if (ImGui::SmallButton("+ Viz##viz_add")) {
VizPanel p;
p.display = ViewMode::Bar;
if (view_mode_needs_aggregation(p.display)) {
auto_promote_aggregated(st);
}
st.extra_panels.push_back(p);
}
draw_viz_config_popup(st);
ImGui::NewLine();
}
// ---------------------------------------------------------------------------
// maybe_recompute_stats
// ---------------------------------------------------------------------------
void maybe_recompute_stats(State& st,
const char* const* cells,
int row_count, int orig_cols, int eff_cols,
const std::vector<Filter>& active_filters,
const std::vector<int>& visible_rows,
const std::vector<int>& src_for_eff)
{
if (!st.stats_mode) return;
size_t fh = filters_hash(active_filters);
bool ds_changed = (cells != st.stats_last_cells || row_count != st.stats_last_rows ||
eff_cols != st.stats_last_eff_cols ||
(int)st.stats_cache.size() != eff_cols);
bool fl_changed = (fh != st.stats_last_filter_h ||
(int)visible_rows.size() != st.stats_last_visible);
if (!ds_changed && !fl_changed) return;
st.stats_cache.resize(eff_cols);
const int* idx = visible_rows.empty() ? nullptr : visible_rows.data();
int n = (int)visible_rows.size();
for (int c = 0; c < eff_cols; ++c) {
int src = src_for_eff[c];
st.stats_cache[c] = compute_column_stats(cells, row_count, orig_cols, src,
100000, idx, n);
}
st.stats_last_cells = cells;
st.stats_last_rows = row_count;
st.stats_last_eff_cols = eff_cols;
st.stats_last_filter_h = fh;
st.stats_last_visible = (int)visible_rows.size();
}
} // namespace data_table
+79
View File
@@ -0,0 +1,79 @@
#pragma once
// data_table_viz_panels — paneles de visualizacion lateral de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// - draw_table_toggle: boton de toggle entre modo tabla y ultimo modo viz.
// - draw_extra_panel: render de un panel de viz adicional (VizPanel) con su
// toolbar mini (titulo, pin/close, selector de chart).
// - draw_viz_config_popup: popup de configuracion de visualizacion (columnas
// x/y, modo, config del grafico activo).
// - draw_viz_selector: selector de modo de viz (iconos/grid de ViewMode).
// - maybe_recompute_stats: recalcula las estadisticas de columna si cambiaron
// los datos visibles (para el stats overlay en headers y config popup).
//
// Rangos del fuente original:
// - draw_table_toggle : lineas 1527-1552
// - draw_extra_panel : lineas 1553-1638
// - draw_viz_config_popup : lineas 1640-1815
// - draw_viz_selector : lineas 1816-1892
// - maybe_recompute_stats : lineas 2474-2502
//
// Dependencias: data_table_types.h, viz_render.h (viz::render), imgui.h.
#include "core/data_table_types.h"
#include "imgui.h"
#include <vector>
#include <string>
namespace data_table {
// ---------------------------------------------------------------------------
// draw_table_toggle
// Boton que cambia display entre ViewMode::Table y el ultimo modo viz no-tabla.
// `id_suffix` diferencia multiples toggles en la misma ventana.
// `st_ptr` si no null se usa para actualizar `st.display` directamente;
// si null usa display/last_non_table pasados por ref.
// ---------------------------------------------------------------------------
void draw_table_toggle(ViewMode& display, ViewMode& last_non_table,
const char* id_suffix,
State* st_ptr = nullptr);
// ---------------------------------------------------------------------------
// draw_extra_panel
// Dibuja un VizPanel adicional (child window con toolbar mini + chart).
// Retorna true si el usuario cerro el panel (el caller debe borrar el entry).
// ---------------------------------------------------------------------------
bool draw_extra_panel(State& st, VizPanel& p, int idx,
const StageOutput& so,
const std::vector<ColumnSpec>* col_specs);
// ---------------------------------------------------------------------------
// draw_viz_config_popup
// Popup de configuracion de visualizacion: columnas x/y, modo de chart,
// colores, histogramas bins, pie radius, etc. Mutates st.viz_config y
// st.extra_panels.
// ---------------------------------------------------------------------------
void draw_viz_config_popup(State& st);
// ---------------------------------------------------------------------------
// draw_viz_selector
// Selector de modo de visualizacion: grid de iconos con ViewMode disponibles.
// Se abre via boton "Ask AI" o desde draw_viz_config_popup.
// ---------------------------------------------------------------------------
void draw_viz_selector(State& st);
// ---------------------------------------------------------------------------
// maybe_recompute_stats
// Recalcula st.stats_cache si el snapshot de datos ha cambiado
// (se detecta por hash de visible_rows + filtros). Solo recalcula si
// st.stats_mode == true.
// ---------------------------------------------------------------------------
void maybe_recompute_stats(State& st,
const char* const* cells,
int row_count, int orig_cols, int eff_cols,
const std::vector<Filter>& active_filters,
const std::vector<int>& visible_rows,
const std::vector<int>& src_for_eff);
} // namespace data_table
@@ -0,0 +1,97 @@
---
name: data_table_viz_panels
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "bool data_table::draw_extra_panel(State& st, VizPanel& p, int idx, const StageOutput& so, const std::vector<ColumnSpec>* col_specs)"
description: "Paneles de visualizacion lateral de la tabla TQL: toggle tabla/chart, paneles extra independientes (histogramas, linea, scatter, value-counts), popup de configuracion de viz (cols x/y, modo, color, bins), selector de ViewMode via grid de iconos, y recalculo lazy de estadisticas de columna. Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, charts, visualization, tql, cpp-tables, implot]
uses_functions:
- viz_render_cpp_viz
- compute_column_stats_cpp_core
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui, implot]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_viz_panels.cpp"
framework: imgui
params:
- name: st
desc: "State mutable: st.display (modo actual), st.viz_config (config de viz), st.extra_panels (paneles adicionales), st.stats_cache/stats_mode (estadisticas)."
- name: p
desc: "VizPanel a renderizar: contiene su propio ViewConfig, titulo, display mode. Mutado si el usuario cambia la config del panel."
- name: idx
desc: "Indice del panel en st.extra_panels. Usado como ID ImGui para unicidad."
- name: so
desc: "StageOutput del stage activo: headers, types, cells row-major. Input de viz::render."
- name: col_specs
desc: "ColumnSpecs del TableInput principal si no vacias; null si no hay specs declarativas."
output: "Bool para draw_extra_panel: true si el usuario cerro el panel (caller debe hacer erase de st.extra_panels[idx]). Void para el resto."
---
## Documentacion
Sub-funcion que encapsula los paneles de visualizacion lateral de la tabla TQL. Permite al usuario ver graficos (bar, line, scatter, pie, etc.) en paralelo a la tabla de datos, con configuracion interactiva.
### Funciones publicas
| Funcion | Que hace |
|---|---|
| `draw_table_toggle(display, last_non_table, id, st_ptr)` | Boton toggle "Table / Chart". Guarda el ultimo modo viz en `last_non_table`. |
| `draw_extra_panel(st, p, idx, so, col_specs)` | Child window con header/footer mini: titulo, pin, close. Llama `viz::render`. Retorna true si cerrado. |
| `draw_viz_config_popup(st)` | Popup tabbado: config de viz principal + config de cada panel extra. Permite cambiar columnas x/y, modo, color, bins, anadir panel. |
| `draw_viz_selector(st)` | Grid de iconos ViewMode; seleccion cambia `st.display`. Incluye boton "Ask AI" -> abre `data_table_ai_panel`. |
| `maybe_recompute_stats(st, cells, ...)` | Recalcula stats lazy si `visible_rows` cambio. Solo cuando `st.stats_mode == true`. |
### draw_extra_panel: ciclo de vida de un VizPanel
1. El usuario abre un panel desde `draw_viz_selector` (boton "+").
2. Se agrega un `VizPanel` a `st.extra_panels` con config por defecto.
3. Cada frame: `draw_extra_panel(st, p, i, so, specs)`.
4. Si retorna `true`: `st.extra_panels.erase(begin + i)`.
### maybe_recompute_stats: politica de cache
Compara un hash de `visible_rows` (FNV-1a sobre el vector de indices) contra `st.stats_last_visible_hash`. Si difiere, llama `compute_column_stats_cpp_core` por cada columna efectiva y actualiza `st.stats_cache`. Costo O(cols * visible_rows) — llamar solo cuando stats_mode este activo.
## Ejemplo
```cpp
// Llamado desde render() tras el grid (path stage 0):
if (st.display != data_table::ViewMode::Table) {
data_table::draw_table_toggle(st.display, U.last_non_table_main, "main", &st);
}
// Paneles extra cuando NO estamos en modo tabla:
if (st.display != data_table::ViewMode::Table && !st.extra_panels.empty()) {
int close_idx = -1;
for (int i = 0; i < (int)st.extra_panels.size(); ++i) {
if (data_table::draw_extra_panel(st, st.extra_panels[i], i, so, &main_t.column_specs))
close_idx = i;
}
if (close_idx >= 0) st.extra_panels.erase(st.extra_panels.begin() + close_idx);
}
// Config popup (activado por boton en draw_viz_selector):
data_table::draw_viz_config_popup(st);
data_table::draw_viz_selector(st);
```
## Cuando usarla
Llamar desde el entrypoint thin `data_table::render()` despues del grid y antes de los modales. No llamar directamente desde apps — la API publica es siempre `data_table::render()`.
## Gotchas
- `draw_extra_panel` abre un `ImGui::BeginChild` interno — no anidar dentro de otro child que ya recorte el area de pintado.
- `draw_viz_selector` incluye la apertura del modal Ask AI (`st.ask_open = true`). El modal real lo dibuja `data_table_ai_panel_cpp_viz`. El order de calls importa: selector primero, luego el modal.
- `maybe_recompute_stats` es potencialmente caro (O(visible_rows * cols)). Solo activar con `st.stats_mode = true` via boton "Show stats"; el boton vive en el area de chrome del render principal.
- ImPlot context debe estar activo cuando se llama `viz::render` desde `draw_extra_panel`. Garantizado si el caller usa `fn::run_app` con ImPlot inicializado en `cfg`.
+28 -26
View File
@@ -5,32 +5,24 @@
#include <imgui.h>
#include <cstdio>
void kpi_card(const char* label, float value, float delta_percent,
const float* history, int history_count,
const char* format,
const char* icon) {
static void kpi_card_impl(const char* label, float value, float delta_percent,
const float* history, int history_count,
const char* format, const char* icon,
bool fixed_y, float y_min, float y_max) {
using namespace fn_tokens;
// Card container — surface bg, border, rounded, padding.
// 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::sm, spacing::sm));
// 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);
float width = ImGui::GetContentRegionAvail().x;
if (width < 1.0f) width = 1.0f;
// Altura fija (no AutoResizeY) para que:
// (a) todas las cards de un grid queden alineadas visualmente,
// (b) no haya recalculo de layout por card en cada resize.
constexpr float card_height = 86.0f;
ImGui::BeginChild(child_id, ImVec2(width, card_height),
ImGuiChildFlags_Borders,
@@ -39,8 +31,6 @@ void kpi_card(const char* label, float value, float delta_percent,
const bool has_history = history != nullptr && history_count > 0;
const bool has_delta = delta_percent != 0.0f;
// Layout de dos columnas: izquierda info (label, value, delta), derecha sparkline.
// El sparkline se sitia verticalmente centrado a la derecha de la card.
constexpr float spark_w = 100.0f;
constexpr float spark_h = 36.0f;
@@ -50,7 +40,6 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::BeginGroup();
ImGui::PushItemWidth(info_w);
// Top row: optional icon + label, ambos en text_muted.
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted);
if (icon && *icon) {
ImGui::TextUnformatted(icon);
@@ -59,14 +48,12 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::TextUnformatted(label);
ImGui::PopStyleColor();
// Value — escala compacta 1.4x, proporcional a una card de 86px.
ImGui::SetWindowFontScale(1.4f);
char value_buf[64];
std::snprintf(value_buf, sizeof(value_buf), format, value);
ImGui::TextUnformatted(value_buf);
ImGui::SetWindowFontScale(1.0f);
// Delta / trend — SIEMPRE se reserva la linea aunque no haya tendencia.
if (has_delta) {
const bool positive = delta_percent >= 0.0f;
const ImVec4 delta_color = positive ? colors::success : colors::error;
@@ -80,7 +67,6 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::TextUnformatted(delta_buf);
ImGui::PopStyleColor();
} else {
// Placeholder para preservar altura.
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim);
ImGui::TextUnformatted(TI_MINUS);
ImGui::PopStyleColor();
@@ -89,24 +75,23 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::PopItemWidth();
ImGui::EndGroup();
// Sparkline a la derecha, centrado verticalmente respecto a la card.
if (has_history) {
const ImVec4 spark_color = has_delta
? (delta_percent >= 0.0f ? colors::success : colors::error)
: colors::primary;
ImGui::SameLine();
// Centrar verticalmente: la card mide card_height, el sparkline spark_h.
// Restamos el padding interno (spacing::sm) ya consumido al inicio.
const float card_inner_h = card_height - 2.0f * spacing::sm;
float y_offset = (card_inner_h - spark_h) * 0.5f;
if (y_offset < 0.0f) y_offset = 0.0f;
// Anclamos el sparkline al borde derecho.
const float spark_x = inner_w - spark_w;
ImGui::SetCursorPos(ImVec2(spacing::sm + spark_x,
spacing::sm + y_offset));
sparkline(label, history, history_count, spark_color, spark_w, spark_h);
ImGui::SetCursorPos(ImVec2(spacing::sm + spark_x, spacing::sm + y_offset));
if (fixed_y) {
sparkline(label, history, history_count, spark_color,
y_min, y_max, spark_w, spark_h);
} else {
sparkline(label, history, history_count, spark_color, spark_w, spark_h);
}
}
ImGui::EndChild();
@@ -114,3 +99,20 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::PopStyleVar(3);
ImGui::PopStyleColor(2);
}
void kpi_card(const char* label, float value, float delta_percent,
const float* history, int history_count,
const char* format,
const char* icon) {
kpi_card_impl(label, value, delta_percent, history, history_count,
format, icon, /*fixed_y=*/false, 0.0f, 0.0f);
}
void kpi_card(const char* label, float value, float delta_percent,
const float* history, int history_count,
float y_min, float y_max,
const char* format,
const char* icon) {
kpi_card_impl(label, value, delta_percent, history, history_count,
format, icon, /*fixed_y=*/true, y_min, y_max);
}
+10 -1
View File
@@ -10,9 +10,18 @@
// - Optional icon (Tabler glyph) + label (small, muted) on top row
// - Value (large font)
// - Delta badge (green TI_TRENDING_UP / red TI_TRENDING_DOWN)
// - Sparkline of history
// - Sparkline of history (auto Y by default; fixed Y in overload v1.4)
void kpi_card(const char* label, float value, float delta_percent,
const float* history = nullptr, int history_count = 0,
const char* format = "%.1f",
const char* icon = nullptr);
// Fixed Y-scale variant (v1.4): el sparkline interno usa [y_min, y_max]
// absolutos. Util para metricas con dominio conocido (CPU%/RAM% -> 0,100)
// donde la comparacion visual entre cards exige misma escala.
void kpi_card(const char* label, float value, float delta_percent,
const float* history, int history_count,
float y_min, float y_max,
const char* format = "%.1f",
const char* icon = nullptr);
+6 -2
View File
@@ -3,11 +3,11 @@ name: kpi_card
kind: component
lang: cpp
domain: viz
version: "1.3.0"
version: "1.4.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\", const char* icon = nullptr)"
description: "Card de KPI con icono opcional + label, valor grande, delta porcentual con TI_TRENDING_UP/DOWN y sparkline historico. Contenedor con surface bg, borde y radius via tokens (Mantine Paper equivalente)."
tags: [imgui, kpi, card, dashboard, metrics, sparkline, tokens, tabler]
tags: [imgui, kpi, card, dashboard, metrics, sparkline, tokens, tabler, cpp-dashboard-viz]
uses_functions: ["sparkline_cpp_viz", "tokens_cpp_core"]
uses_types: []
returns: []
@@ -79,3 +79,7 @@ ImGui::Columns(1);
- La linea de trend siempre se reserva (delta, sparkline o em dash placeholder en `text_dim`) para que un grid de KPIs quede alineado vertical.
- ~~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.~~ → Obsoleto en v1.3: ahora se usan glyphs Tabler que estan en el atlas mergeado por `icon_font_cpp_core`.
- Colores: delta usa `fn_tokens::colors::{success, error}`, placeholder `TI_MINUS` usa `text_dim`, label + icono usan `text_muted`.
## Capability growth log
- v1.4.0 (2026-05-18) — Overload con `y_min, y_max` que se propaga al sparkline interno. Cards de un grid con dominio fijo (CPU%/RAM% -> 0,100) ahora son visualmente comparables — outliers no aplastan la escala. Default sigue siendo auto-scale.
+68 -16
View File
@@ -5,28 +5,48 @@
namespace {
template <typename T>
void draw_line(const char* title, const T* xs, const T* ys, int count, float height) {
void draw_line(const char* title, const T* xs, const T* ys, int count,
float height, bool auto_x, double x_lo_in, double x_hi_in,
bool auto_y, double y_lo_in, double y_hi_in) {
if (count <= 0) return;
T x_min = xs[0], x_max = xs[0];
T y_min = ys[0], y_max = ys[0];
for (int i = 1; i < count; i++) {
if (xs[i] < x_min) x_min = xs[i];
if (xs[i] > x_max) x_max = xs[i];
if (ys[i] < y_min) y_min = ys[i];
if (ys[i] > y_max) y_max = ys[i];
double x_lo, x_hi;
if (auto_x) {
T x_min = xs[0], x_max = xs[0];
for (int i = 1; i < count; i++) {
if (xs[i] < x_min) x_min = xs[i];
if (xs[i] > x_max) x_max = xs[i];
}
x_lo = static_cast<double>(x_min);
x_hi = static_cast<double>(x_max);
} else {
x_lo = x_lo_in;
x_hi = x_hi_in;
}
if (x_hi - x_lo < 1e-9) x_hi = x_lo + 1.0;
double y_lo, y_hi;
if (auto_y) {
T y_min = ys[0], y_max = ys[0];
for (int i = 1; i < count; i++) {
if (ys[i] < y_min) y_min = ys[i];
if (ys[i] > y_max) y_max = ys[i];
}
double dy = static_cast<double>(y_max) - static_cast<double>(y_min);
if (dy < 1e-9) dy = 1.0;
y_lo = static_cast<double>(y_min) - dy * 0.05;
y_hi = static_cast<double>(y_max) + dy * 0.05;
} else {
y_lo = y_lo_in;
y_hi = y_hi_in;
if (y_hi - y_lo < 1e-9) y_hi = y_lo + 1.0;
}
double dy = static_cast<double>(y_max) - static_cast<double>(y_min);
if (dy < 1e-9) dy = 1.0;
double y_lo = static_cast<double>(y_min) - dy * 0.05;
double y_hi = static_cast<double>(y_max) + dy * 0.05;
const ImVec2 plot_size(-1.0f, height > 0.0f ? height : 200.0f);
if (ImPlot::BeginPlot(title, plot_size, plot_static::kPlotFlags)) {
ImPlot::SetupAxes(nullptr, nullptr, plot_static::kAxisFlags, plot_static::kAxisFlags);
ImPlot::SetupAxisLimits(ImAxis_X1, static_cast<double>(x_min),
static_cast<double>(x_max), ImPlotCond_Always);
ImPlot::SetupAxisLimits(ImAxis_X1, x_lo, x_hi, ImPlotCond_Always);
ImPlot::SetupAxisLimits(ImAxis_Y1, y_lo, y_hi, ImPlotCond_Always);
ImPlot::PlotLine("##data", xs, ys, count);
ImPlot::EndPlot();
@@ -36,9 +56,41 @@ void draw_line(const char* title, const T* xs, const T* ys, int count, float hei
} // namespace
void line_plot(const char* title, const float* xs, const float* ys, int count, float height) {
draw_line<float>(title, xs, ys, count, height);
draw_line<float>(title, xs, ys, count, height,
/*auto_x=*/true, 0.0, 0.0,
/*auto_y=*/true, 0.0, 0.0);
}
void line_plot(const char* title, const double* xs, const double* ys, int count, float height) {
draw_line<double>(title, xs, ys, count, height);
draw_line<double>(title, xs, ys, count, height,
/*auto_x=*/true, 0.0, 0.0,
/*auto_y=*/true, 0.0, 0.0);
}
void line_plot(const char* title, const float* xs, const float* ys, int count,
float y_min, float y_max, float height) {
draw_line<float>(title, xs, ys, count, height,
/*auto_x=*/true, 0.0, 0.0,
/*auto_y=*/false, (double)y_min, (double)y_max);
}
void line_plot(const char* title, const double* xs, const double* ys, int count,
double y_min, double y_max, float height) {
draw_line<double>(title, xs, ys, count, height,
/*auto_x=*/true, 0.0, 0.0,
/*auto_y=*/false, y_min, y_max);
}
void line_plot(const char* title, const float* xs, const float* ys, int count,
float x_min, float x_max, float y_min, float y_max, float height) {
draw_line<float>(title, xs, ys, count, height,
/*auto_x=*/false, (double)x_min, (double)x_max,
/*auto_y=*/false, (double)y_min, (double)y_max);
}
void line_plot(const char* title, const double* xs, const double* ys, int count,
double x_min, double x_max, double y_min, double y_max, float height) {
draw_line<double>(title, xs, ys, count, height,
/*auto_x=*/false, x_min, x_max,
/*auto_y=*/false, y_min, y_max);
}
+17
View File
@@ -8,3 +8,20 @@ void line_plot(const char* title, const float* xs, const float* ys, int count,
float height = 200.0f);
void line_plot(const char* title, const double* xs, const double* ys, int count,
float height = 200.0f);
// Fixed Y-range overloads — pinea Y a [y_min, y_max] para series con dominio
// conocido (ej. 0-100 en CPU%/RAM%). v1.2.
void line_plot(const char* title, const float* xs, const float* ys, int count,
float y_min, float y_max, float height = 200.0f);
void line_plot(const char* title, const double* xs, const double* ys, int count,
double y_min, double y_max, float height = 200.0f);
// Fixed XY-range overloads — pinea BOTH X y Y. Util para ventanas temporales
// fijas (ej. ultimos 5 min de CPU%): xs en [0, WINDOW] y ys en [0, 100]. El
// plot NO se aplasta al variar count. v1.3.
void line_plot(const char* title, const float* xs, const float* ys, int count,
float x_min, float x_max, float y_min, float y_max,
float height = 200.0f);
void line_plot(const char* title, const double* xs, const double* ys, int count,
double x_min, double x_max, double y_min, double y_max,
float height = 200.0f);
+7 -2
View File
@@ -3,11 +3,11 @@ name: line_plot
kind: component
lang: cpp
domain: viz
version: "1.1.0"
version: "1.3.0"
purity: pure
signature: "void line_plot(const char* title, const float* xs, const float* ys, int count, float height = 200.0f)"
description: "Line plot 2D con ImPlot, ejes pineados y altura explicita para no vibrar al redimensionar"
tags: [implot, chart, visualization, gpu, line, locked-axes]
tags: [implot, chart, visualization, gpu, line, locked-axes, cpp-dashboard-viz]
uses_functions: ["plot_static_cpp_viz"]
uses_types: []
returns: []
@@ -45,3 +45,8 @@ Wrapper atomico sobre `ImPlot::PlotLine` configurado para visualizacion estatica
- **Sin inputs, sin auto-fit** — ver `viz/plot_static.h`.
Soporta `float` y `double`.
## Capability growth log
- v1.2.0 (2026-05-18) — Overloads `(..., y_min, y_max, height)` para series con dominio conocido (CPU%/RAM% -> 0,100). Mantiene los overloads de auto-fit historico intactos. Tipos float y double.
- v1.3.0 (2026-05-18) — Overloads `(..., x_min, x_max, y_min, y_max, height)` que pinea AMBOS ejes. Util para ventanas temporales fijas (ej. ultimos 5 min): xs en [0, 300], el grafico NO se aplasta al variar count durante warmup.
+63 -38
View File
@@ -1,8 +1,11 @@
#include "viz/sparkline.h"
#include "imgui.h"
void sparkline(const char* id, const float* values, int count, ImVec4 color,
float width, float height) {
// Implementacion comun. Si auto_y=true, calcula min/max de values; si no,
// usa [y_min, y_max] explicitos.
static void sparkline_impl(const char* id, const float* values, int count,
ImVec4 color, float width, float height,
bool auto_y, float y_min, float y_max) {
if (count <= 0) return;
ImGui::PushID(id);
@@ -10,67 +13,89 @@ void sparkline(const char* id, const float* values, int count, ImVec4 color,
ImVec2 pos = ImGui::GetCursorScreenPos();
ImDrawList* draw_list = ImGui::GetWindowDrawList();
// Reserve inline space
ImGui::Dummy(ImVec2(width, height));
// Find min/max for Y auto-scale
float min_val = values[0];
float max_val = values[0];
for (int i = 1; i < count; i++) {
if (values[i] < min_val) min_val = values[i];
if (values[i] > max_val) max_val = values[i];
if (auto_y) {
y_min = values[0];
y_max = values[0];
for (int i = 1; i < count; i++) {
if (values[i] < y_min) y_min = values[i];
if (values[i] > y_max) y_max = values[i];
}
}
float range = max_val - min_val;
if (range < 1e-6f) range = 1.0f; // avoid division by zero for flat lines
float range = y_max - y_min;
if (range < 1e-6f) range = 1.0f;
auto y_at = [&](float v) {
// Clamp visualmente al rango — para fixed Y sirve para que outliers
// no salgan del card; para auto Y es no-op.
if (v < y_min) v = y_min;
if (v > y_max) v = y_max;
return pos.y + height - ((v - y_min) / range) * height;
};
// Fade gradient v1.2: alpha sube de older->newer.
// El segmento [i, i+1] se pinta con alpha proporcional al endpoint derecho
// (mas cercano a "ahora"). Hace efecto de rastro / trail.
const int r_ = (int)(color.x * 255);
const int g_ = (int)(color.y * 255);
const int b_ = (int)(color.z * 255);
auto seg_t = [&](int i) {
return (count > 1) ? (float)(i + 1) / (float)(count - 1) : 1.0f;
};
// Fill area under curve (low alpha)
if (count >= 2) {
ImU32 fill_color = IM_COL32(
(int)(color.x * 255),
(int)(color.y * 255),
(int)(color.z * 255),
40);
// Build fill polygon: baseline bottom-left -> points -> baseline bottom-right
// We use AddConvexPolyFilled workaround: draw as a series of triangles from baseline
float x0 = pos.x;
float y_base = pos.y + height;
for (int i = 0; i + 1 < count; i++) {
float t = seg_t(i);
int fill_a = (int)((0.10f + 0.70f * t) * 40.0f); // 4..28 alpha
ImU32 fill_color = IM_COL32(r_, g_, b_, fill_a);
float xa = x0 + ((float)i / (count - 1)) * width;
float xb = x0 + ((float)(i + 1) / (count - 1)) * width;
float ya = pos.y + height - ((values[i] - min_val) / range) * height;
float yb = pos.y + height - ((values[i + 1] - min_val) / range) * height;
float ya = y_at(values[i]);
float yb = y_at(values[i + 1]);
draw_list->AddQuadFilled(
ImVec2(xa, y_base),
ImVec2(xa, ya),
ImVec2(xb, yb),
ImVec2(xb, y_base),
ImVec2(xa, y_base), ImVec2(xa, ya),
ImVec2(xb, yb), ImVec2(xb, y_base),
fill_color);
}
}
// Draw polyline
ImU32 line_color = IM_COL32(
(int)(color.x * 255),
(int)(color.y * 255),
(int)(color.z * 255),
(int)(color.w * 255));
for (int i = 0; i + 1 < count; i++) {
float t = seg_t(i);
float a = 0.20f + 0.80f * t;
ImU32 line_color = IM_COL32(r_, g_, b_, (int)(color.w * a * 255));
float xa = pos.x + ((float)i / (count - 1)) * width;
float xb = pos.x + ((float)(i + 1) / (count - 1)) * width;
float ya = pos.y + height - ((values[i] - min_val) / range) * height;
float yb = pos.y + height - ((values[i + 1] - min_val) / range) * height;
float ya = y_at(values[i]);
float yb = y_at(values[i + 1]);
draw_list->AddLine(ImVec2(xa, ya), ImVec2(xb, yb), line_color, 1.5f);
}
ImGui::PopID();
}
void sparkline(const char* id, const float* values, int count, ImVec4 color,
float width, float height) {
sparkline_impl(id, values, count, color, width, height,
/*auto_y=*/true, 0.0f, 0.0f);
}
void sparkline(const char* id, const float* values, int count,
float width, float height) {
// Default color: soft green
sparkline(id, values, count, ImVec4(0.35f, 0.85f, 0.45f, 1.0f), width, height);
}
void sparkline(const char* id, const float* values, int count, ImVec4 color,
float y_min, float y_max, float width, float height) {
sparkline_impl(id, values, count, color, width, height,
/*auto_y=*/false, y_min, y_max);
}
void sparkline(const char* id, const float* values, int count,
float y_min, float y_max, float width, float height) {
sparkline(id, values, count, ImVec4(0.35f, 0.85f, 0.45f, 1.0f),
y_min, y_max, width, height);
}
+14 -1
View File
@@ -3,10 +3,23 @@
#include "imgui.h"
// Renders a mini inline line chart for use in tables, headers and KPI cards.
// Auto-scales Y to the min/max of values.
// Uses PushID/PopID with id for uniqueness inside tables.
//
// Auto Y-scale variants:
void sparkline(const char* id, const float* values, int count,
float width = 100.0f, float height = 20.0f);
void sparkline(const char* id, const float* values, int count, ImVec4 color,
float width = 100.0f, float height = 20.0f);
// Fixed Y-scale variants — clamp the polyline to [y_min, y_max] so cards in a
// grid stay visually comparable (ej. CPU% / RAM%: pasar 0,100). v1.1.
// width y height son explicitos (sin default) para que el compilador no haga
// match contra los overloads sin y_min/y_max.
void sparkline(const char* id, const float* values, int count,
float y_min, float y_max,
float width, float height);
void sparkline(const char* id, const float* values, int count, ImVec4 color,
float y_min, float y_max,
float width, float height);
+7 -2
View File
@@ -3,11 +3,11 @@ name: sparkline
kind: component
lang: cpp
domain: viz
version: "1.0.0"
version: "1.2.0"
purity: pure
signature: "void sparkline(const char* id, const float* values, int count, float width = 100.0f, float height = 20.0f)"
description: "Renderiza un mini grafico de lineas inline para uso en tablas, headers y KPI cards"
tags: [imgui, visualization, sparkline, inline, dashboard]
tags: [imgui, visualization, sparkline, inline, dashboard, data-table-renderers, cpp-dashboard-viz]
uses_functions: []
uses_types: []
returns: []
@@ -69,3 +69,8 @@ sparkline("kpi_rev", data, count);
- Si todos los valores son iguales (rango < 1e-6), la linea se dibuja en el centro verticalmente.
- El grosor de linea es 1.5px para que sea legible a alturas de 16-24px.
- `id` no se muestra visualmente; solo se pasa a `PushID` para que ImGui diferencie widgets con los mismos datos en la misma tabla.
## Capability growth log
- v1.1.0 (2026-05-18) — Overloads con `y_min, y_max` explicitos. Cuando la metrica tiene dominio conocido (CPU%/RAM% -> 0,100) varias cards comparten escala y son comparables visualmente. Outliers se clampan al rango. Variant sin bounds preserva auto-scale historico.
- v1.2.0 (2026-05-18) — Alpha fade gradient default: segmento i pinta con alpha lerp(0.2, 1.0, i/(count-1)). Older -> faded, newest -> bright. Efecto "trail" hacia el numero actual. Fill bajo curva tambien fade (alpha 4..28). Cambio visual sin cambio de API.
+10
View File
@@ -256,6 +256,16 @@ add_fn_test(test_tql_to_sql test_tql_to_sql.cpp
add_fn_test(test_llm_anthropic test_llm_anthropic.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/llm_anthropic.cpp)
# --- Issue 0109a — parse_md_frontmatter: pure YAML-subset frontmatter parser --
add_fn_test(test_parse_md_frontmatter test_parse_md_frontmatter.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/parse_md_frontmatter.cpp)
target_compile_definitions(test_parse_md_frontmatter PRIVATE
FN_TEST_REPO_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/../..")
# --- Issue 0109b — compute_ring_layout: geometria pura para skill_tree -------
add_fn_test(test_compute_ring_layout test_compute_ring_layout.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/compute_ring_layout.cpp)
# --- Visual golden-image diff (issue 0048) ---------------------------------
# El binario primitives_gallery se compila con --capture; el test compara los
# PNGs generados con los goldens en cpp/tests/golden/. Si no hay goldens o el
+337
View File
@@ -0,0 +1,337 @@
// Tests para fn_ring::compute_ring_layout (cpp/functions/core/compute_ring_layout).
// Pure: sin ImGui context, sin I/O.
// Issue 0109b — skill_tree ring layout.
#define CATCH_CONFIG_MAIN
#include "catch_amalgamated.hpp"
#include "core/compute_ring_layout.h"
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
using namespace fn_ring;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static const LayoutConfig kDefault; // default 5 rings, 18 sectors
static LayoutOutput find_by_id(const std::vector<LayoutOutput>& out,
const std::string& id) {
for (auto& o : out) {
if (o.id == id) return o;
}
// not found — return sentinel with ring=-99
LayoutOutput bad;
bad.id = id;
bad.ring = -99;
return bad;
}
static float dist2(float x, float y) {
return std::sqrt(x * x + y * y);
}
// ---------------------------------------------------------------------------
// Test 1: empty input → empty output
// ---------------------------------------------------------------------------
TEST_CASE("empty_input_empty_output") {
auto out = compute_ring_layout({}, kDefault);
REQUIRE(out.empty());
}
// ---------------------------------------------------------------------------
// Test 2: un solo nodo cae en centro de su banda radial + centro de su sector
// ---------------------------------------------------------------------------
TEST_CASE("single_node_centered_in_bin") {
std::vector<LayoutInput> nodes = {{"n1", "completado", "core", 1.0f}};
// "completado" → ring 0, ring_radii default: [0, 150)
// r_inner efectivo = 30 (ring0 avoidance), r_outer = 150
// r_lo = 30 + 14 = 44, r_hi = 150 - 14 = 136, center = (44+136)/2 = 90
auto out = compute_ring_layout(nodes, kDefault);
REQUIRE(out.size() == 1);
auto o = out[0];
REQUIRE(o.ring == 0);
REQUIRE(o.sector == 17); // "core" no esta en DomainOrder vacia → sector n_sectors-1 = 17
float r = dist2(o.x, o.y);
// Centro de banda: [44, 136] -> 90.0
REQUIRE(std::abs(r - 90.0f) < 0.01f);
}
// ---------------------------------------------------------------------------
// Test 3: dos nodos en mismo bin → radios distintos, uniformemente distribuidos
// ---------------------------------------------------------------------------
TEST_CASE("two_nodes_same_bin_radial_distribution") {
std::vector<LayoutInput> nodes = {
{"a", "completado", "alpha", 0.5f},
{"b", "completado", "alpha", 0.5f},
};
DomainOrder order = {"alpha"};
auto out = compute_ring_layout(nodes, kDefault, {}, order);
REQUIRE(out.size() == 2);
auto oa = find_by_id(out, "a");
auto ob = find_by_id(out, "b");
REQUIRE(oa.ring == 0);
REQUIRE(ob.ring == 0);
REQUIRE(oa.sector == 0);
REQUIRE(ob.sector == 0);
float ra = dist2(oa.x, oa.y);
float rb = dist2(ob.x, ob.y);
// Radios deben ser distintos
REQUIRE(std::abs(ra - rb) > 0.01f);
// Ambos deben estar en la banda [44, 136]
float r_lo = 44.0f; // 30 + 14
float r_hi = 136.0f; // 150 - 14
REQUIRE(ra >= r_lo - 0.01f);
REQUIRE(ra <= r_hi + 0.01f);
REQUIRE(rb >= r_lo - 0.01f);
REQUIRE(rb <= r_hi + 0.01f);
// N=2: r_0 = 44 + 0.5*(136-44)/2 = 44+23 = 67; r_1 = 44+1.5*46 = 113
// (aprox, sin jitter porque bin tiene 2 nodos y capacidad radial = band/18 ~ 5)
// No verificamos valores exactos porque el jitter angular puede activarse,
// pero la diferencia de radios debe ser aprox (r_hi-r_lo)/N = 46
float expected_step = (r_hi - r_lo) / 2.0f; // 46
REQUIRE(std::abs(std::abs(ra - rb) - expected_step) < 0.5f);
}
// ---------------------------------------------------------------------------
// Test 4: default status map mapea correctamente
// ---------------------------------------------------------------------------
TEST_CASE("default_status_map") {
std::vector<LayoutInput> nodes = {
{"c1", "completado", "d", 0.0f},
{"c2", "completed", "d", 0.0f},
{"c3", "in-progress", "d", 0.0f},
{"c4", "pendiente", "d", 0.0f},
{"c5", "deferred", "d", 0.0f},
{"c6", "locked", "d", 0.0f},
{"c7", "unlocked", "d", 0.0f},
{"c8", "bloqueado", "d", 0.0f},
};
auto out = compute_ring_layout(nodes, kDefault);
REQUIRE(find_by_id(out, "c1").ring == 0);
REQUIRE(find_by_id(out, "c2").ring == 0);
REQUIRE(find_by_id(out, "c3").ring == 1);
REQUIRE(find_by_id(out, "c4").ring == 3);
REQUIRE(find_by_id(out, "c5").ring == 4);
REQUIRE(find_by_id(out, "c6").ring == 3);
REQUIRE(find_by_id(out, "c7").ring == 2);
REQUIRE(find_by_id(out, "c8").ring == 4);
}
// ---------------------------------------------------------------------------
// Test 5: status no mapeado → ring == -1, x/y == 0
// ---------------------------------------------------------------------------
TEST_CASE("unmapped_status_returns_ring_minus_one") {
std::vector<LayoutInput> nodes = {{"x1", "unknown_status", "core", 0.5f}};
auto out = compute_ring_layout(nodes, kDefault);
REQUIRE(out.size() == 1);
auto o = out[0];
REQUIRE(o.ring == -1);
REQUIRE(o.x == 0.0f);
REQUIRE(o.y == 0.0f);
}
// ---------------------------------------------------------------------------
// Test 6: domain fuera del orden → sector n_sectors-1
// ---------------------------------------------------------------------------
TEST_CASE("domain_not_in_order_falls_back_to_last_sector") {
DomainOrder order = {"alpha", "beta", "gamma"};
std::vector<LayoutInput> nodes = {{"n1", "completado", "unknown_domain", 0.5f}};
LayoutConfig cfg = kDefault;
cfg.n_sectors = 5;
auto out = compute_ring_layout(nodes, cfg, {}, order);
REQUIRE(out.size() == 1);
REQUIRE(out[0].sector == 4); // n_sectors-1 = 4
}
// ---------------------------------------------------------------------------
// Test 7: determinismo — dos llamadas identicas producen el mismo output
// ---------------------------------------------------------------------------
TEST_CASE("deterministic_repeated_call") {
std::vector<LayoutInput> nodes = {
{"0001", "completado", "core", 1.0f},
{"0002", "pendiente", "infra", 0.5f},
{"0003", "in-progress", "finance", 0.8f},
{"0004", "deferred", "core", 0.1f},
{"0005", "completado", "infra", 0.9f},
};
auto out1 = compute_ring_layout(nodes, kDefault);
auto out2 = compute_ring_layout(nodes, kDefault);
REQUIRE(out1.size() == out2.size());
for (size_t i = 0; i < out1.size(); ++i) {
REQUIRE(out1[i].id == out2[i].id);
REQUIRE(out1[i].x == out2[i].x);
REQUIRE(out1[i].y == out2[i].y);
REQUIRE(out1[i].ring == out2[i].ring);
REQUIRE(out1[i].sector == out2[i].sector);
REQUIRE(out1[i].rank_in_bin == out2[i].rank_in_bin);
}
}
// ---------------------------------------------------------------------------
// Test 8: ring 0 con radio interno 0 → nodos colocados con r >= 30
// ---------------------------------------------------------------------------
TEST_CASE("ring_zero_avoids_origin") {
// ring_radii[0] == 0 por defecto
std::vector<LayoutInput> nodes = {
{"n1", "completado", "d1", 1.0f},
{"n2", "completed", "d2", 0.9f},
};
auto out = compute_ring_layout(nodes, kDefault);
for (auto& o : out) {
if (o.ring == 0) {
float r = dist2(o.x, o.y);
REQUIRE(r >= 30.0f - 0.01f); // kRing0InnerMin = 30
REQUIRE(r > 0.5f); // definitivamente no en el origen
}
}
}
// ---------------------------------------------------------------------------
// Test 9: sector wrap-around — sector 17 (ultimo) con 18 sectores
// theta ≈ 2*PI*(17+0.5)/18 = 2*PI*17.5/18 ≈ 6.109 rad
// ---------------------------------------------------------------------------
TEST_CASE("sector_wrap_around_last_sector") {
// Forzamos que el nodo caiga en sector 17 pasando domain_order sin "misc"
std::vector<LayoutInput> nodes = {{"n1", "completado", "misc_domain", 1.0f}};
LayoutConfig cfg;
cfg.n_sectors = 18;
cfg.ring_radii = {0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f};
cfg.start_angle = 0.0f;
cfg.bin_padding = 14.0f;
DomainOrder order; // vacia → "misc_domain" cae en sector n_sectors-1 = 17
auto out = compute_ring_layout(nodes, cfg, {}, order);
REQUIRE(out.size() == 1);
REQUIRE(out[0].sector == 17);
// theta = start_angle + (17 + 0.5) * (2*PI / 18) = 17.5 / 18 * 2*PI
const float pi = 3.14159265358979323846f;
const float theta = (17.5f / 18.0f) * 2.0f * pi; // ≈ 6.1087 rad
// Radio del nodo: ring 0, r_inner=30, r_outer=150, padding=14 → [44,136] → 90
const float r = 90.0f;
float expected_x = r * std::cos(theta);
float expected_y = r * std::sin(theta);
REQUIRE(std::abs(out[0].x - expected_x) < 0.05f);
REQUIRE(std::abs(out[0].y - expected_y) < 0.05f);
}
// ---------------------------------------------------------------------------
// Test 10: golden snapshot — 30 nodos, mezcla de status/domain
// Verifica primer y ultimo nodo con tolerancia 0.001
// ---------------------------------------------------------------------------
TEST_CASE("golden_snapshot_30_nodes") {
// Input fijo: 30 nodos con mix de status/domain/recency
const std::vector<LayoutInput> nodes = {
{"0001", "completado", "core", 1.00f},
{"0002", "completado", "infra", 0.95f},
{"0003", "in-progress", "finance", 0.90f},
{"0004", "pendiente", "core", 0.85f},
{"0005", "deferred", "datascience", 0.80f},
{"0006", "completado", "core", 0.75f},
{"0007", "locked", "infra", 0.70f},
{"0008", "unlocked", "finance", 0.65f},
{"0009", "pendiente", "core", 0.60f},
{"0010", "in-progress", "datascience", 0.55f},
{"0011", "completado", "infra", 0.50f},
{"0012", "bloqueado", "core", 0.45f},
{"0013", "deferred", "finance", 0.40f},
{"0014", "pendiente", "infra", 0.35f},
{"0015", "completado", "datascience", 0.30f},
{"0016", "unknown_status", "core", 0.25f}, // descartado
{"0017", "in-progress", "infra", 0.20f},
{"0018", "pendiente_unlocked", "finance", 0.15f},
{"0019", "completed", "datascience", 0.10f},
{"0020", "locked", "core", 0.05f},
{"0021", "completado", "core", 1.00f},
{"0022", "completado", "infra", 0.92f},
{"0023", "pendiente", "finance", 0.88f},
{"0024", "in-progress", "core", 0.84f},
{"0025", "deferred", "infra", 0.80f},
{"0026", "unlocked", "datascience", 0.76f},
{"0027", "completado", "finance", 0.72f},
{"0028", "locked", "datascience", 0.68f},
{"0029", "bloqueado", "core", 0.64f},
{"0030", "pendiente", "finance", 0.60f},
};
DomainOrder order = {"core", "infra", "finance", "datascience"};
LayoutConfig cfg;
cfg.n_sectors = 18;
cfg.ring_radii = {0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f};
cfg.start_angle = 0.0f;
cfg.bin_padding = 14.0f;
cfg.center_x = 0.0f;
cfg.center_y = 0.0f;
auto out = compute_ring_layout(nodes, cfg, {}, order);
REQUIRE(out.size() == 30);
// Nodo "0016" tiene status desconocido → debe ser descartado
auto o16 = find_by_id(out, "0016");
REQUIRE(o16.ring == -1);
REQUIRE(o16.x == 0.0f);
REQUIRE(o16.y == 0.0f);
// Verificar "0001": completado→ring0, core→sector0
auto o1 = find_by_id(out, "0001");
REQUIRE(o1.ring == 0);
REQUIRE(o1.sector == 0);
// theta = 0 + (0 + 0.5) * (2*PI / 18) = PI/18 ≈ 0.17453 rad
// En el bin (ring=0, sector=0) hay "0001", "0006", "0021", "0024"
// ordenados por (recency desc): recency 1.00, 0.75, 1.00 (0021), 0.84
// -> "0001"(1.0), "0021"(1.0, id mayor), "0024"(0.84), "0006"(0.75)
// rank "0001" = 0 si su id < "0021": "0001" < "0021" → rank 0
REQUIRE(o1.rank_in_bin == 0);
// El radio y angulo exactos dependen del jitter deterministico.
// Verificamos que esta dentro de la banda del ring 0: [44, 136]
float r1 = dist2(o1.x, o1.y);
REQUIRE(r1 >= 44.0f - 0.01f);
REQUIRE(r1 <= 136.0f + 0.01f);
// Verificar "0030": pendiente→ring3, finance→sector2
auto o30 = find_by_id(out, "0030");
REQUIRE(o30.ring == 3);
REQUIRE(o30.sector == 2);
// ring 3: [450, 650), r_lo = 450+14 = 464, r_hi = 650-14 = 636
float r30 = dist2(o30.x, o30.y);
REQUIRE(r30 >= 464.0f - 0.01f);
REQUIRE(r30 <= 636.0f + 0.01f);
// El output es deterministico: dos llamadas dan resultado identico
auto out2 = compute_ring_layout(nodes, cfg, {}, order);
for (size_t i = 0; i < out.size(); ++i) {
REQUIRE(out[i].x == out2[i].x);
REQUIRE(out[i].y == out2[i].y);
}
}
+270
View File
@@ -0,0 +1,270 @@
// Tests for parse_md_frontmatter (cpp/functions/core/parse_md_frontmatter).
// Pure function — no ImGui context, no I/O (except the golden-run test which
// reads dev/issues/ using FN_TEST_REPO_ROOT defined at compile time).
#define CATCH_CONFIG_MAIN
#include "catch_amalgamated.hpp"
#include "core/parse_md_frontmatter.h"
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
using namespace fn_md;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static std::string str(const YamlValue& v) {
if (auto* s = std::get_if<std::string>(&v)) return *s;
return "";
}
static std::vector<std::string> lst(const YamlValue& v) {
if (auto* l = std::get_if<std::vector<std::string>>(&v)) return *l;
return {};
}
static bool has(const Frontmatter& fm, const std::string& key) {
return fm.fields.count(key) > 0;
}
// ---------------------------------------------------------------------------
// Case 1 — No frontmatter
// ---------------------------------------------------------------------------
TEST_CASE("parses_no_frontmatter") {
const std::string content = "# Just a markdown file\n\nSome body text.\n";
auto fm = parse_md_frontmatter(content);
REQUIRE_FALSE(fm.has_frontmatter);
REQUIRE(fm.body == content);
REQUIRE(fm.fields.empty());
}
// ---------------------------------------------------------------------------
// Case 2 — Simple key:value
// ---------------------------------------------------------------------------
TEST_CASE("parses_simple_key_value") {
const std::string content =
"---\n"
"title: Hello\n"
"status: pending\n"
"---\n"
"Body here.\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(str(fm.fields.at("title")) == "Hello");
REQUIRE(str(fm.fields.at("status")) == "pending");
REQUIRE(fm.body == "Body here.\n");
}
// ---------------------------------------------------------------------------
// Case 3 — Quoted string (double and single quotes)
// ---------------------------------------------------------------------------
TEST_CASE("parses_quoted_strings") {
const std::string content =
"---\n"
"description: \"foo bar\"\n"
"note: 'baz qux'\n"
"id: \"0109\"\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(str(fm.fields.at("description")) == "foo bar");
REQUIRE(str(fm.fields.at("note")) == "baz qux");
REQUIRE(str(fm.fields.at("id")) == "0109");
}
// ---------------------------------------------------------------------------
// Case 4 — Inline list
// ---------------------------------------------------------------------------
TEST_CASE("parses_inline_list") {
const std::string content =
"---\n"
"tags: [meta, cpp, imgui]\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
const auto v = lst(fm.fields.at("tags"));
REQUIRE(v.size() == 3);
REQUIRE(v[0] == "meta");
REQUIRE(v[1] == "cpp");
REQUIRE(v[2] == "imgui");
}
// ---------------------------------------------------------------------------
// Case 5 — Multiline list
// ---------------------------------------------------------------------------
TEST_CASE("parses_multiline_list") {
const std::string content =
"---\n"
"domain:\n"
" - meta\n"
" - cpp-stack\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
const auto v = lst(fm.fields.at("domain"));
REQUIRE(v.size() == 2);
REQUIRE(v[0] == "meta");
REQUIRE(v[1] == "cpp-stack");
}
// ---------------------------------------------------------------------------
// Case 6 — Body after frontmatter
// ---------------------------------------------------------------------------
TEST_CASE("parses_body_after_frontmatter") {
const std::string content =
"---\n"
"key: v\n"
"---\n"
"body text\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(str(fm.fields.at("key")) == "v");
REQUIRE(fm.body == "body text\n");
}
// ---------------------------------------------------------------------------
// Case 7 — Empty list
// ---------------------------------------------------------------------------
TEST_CASE("parses_empty_inline_list") {
const std::string content =
"---\n"
"blocks: []\n"
"depends: []\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(lst(fm.fields.at("blocks")).empty());
REQUIRE(lst(fm.fields.at("depends")).empty());
}
// ---------------------------------------------------------------------------
// Case 8 — Trailing comments stripped
// ---------------------------------------------------------------------------
TEST_CASE("parses_strips_trailing_comment") {
const std::string content =
"---\n"
"key: value # this is a comment\n"
"other: hello # another\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(str(fm.fields.at("key")) == "value");
REQUIRE(str(fm.fields.at("other")) == "hello");
}
// ---------------------------------------------------------------------------
// Case 9 — Real issue sample (issue 0109)
// ---------------------------------------------------------------------------
TEST_CASE("parses_real_issue_0109") {
const std::string content =
"---\n"
"id: \"0109\"\n"
"title: \"App skill_tree: mapa interactivo de issues+flows en anillos concentricos por estado (roadmap)\"\n"
"status: in-progress\n"
"type: epic\n"
"domain:\n"
" - meta\n"
" - cpp-stack\n"
"scope: cross-stack\n"
"priority: media\n"
"depends: []\n"
"blocks: []\n"
"related:\n"
" - \"0069\"\n"
" - \"0085\"\n"
"created: 2026-05-17\n"
"updated: 2026-05-17\n"
"tags:\n"
" - skill-tree\n"
" - roadmap\n"
" - meta\n"
" - cpp\n"
" - imgui\n"
" - gamification\n"
"---\n"
"\n"
"# Body\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE_FALSE(fm.fields.empty());
REQUIRE(str(fm.fields.at("id")) == "0109");
REQUIRE(str(fm.fields.at("status")) == "in-progress");
const auto domain = lst(fm.fields.at("domain"));
REQUIRE(domain.size() == 2);
REQUIRE(domain[0] == "meta");
REQUIRE(domain[1] == "cpp-stack");
const auto tags = lst(fm.fields.at("tags"));
REQUIRE(tags.size() == 6);
REQUIRE(tags[0] == "skill-tree");
}
// ---------------------------------------------------------------------------
// Case 10 — Golden run: parse ALL dev/issues/*.md and dev/flows/*.md
//
// Requires the compile-time definition FN_TEST_REPO_ROOT pointing to the
// root of the fn_registry repo (set by CMakeLists.txt via
// target_compile_definitions(... PRIVATE FN_TEST_REPO_ROOT="...") ).
//
// The test reads every .md file it finds, parses it, and verifies:
// 1. No crash / no exception.
// 2. If the file starts with `---`, has_frontmatter == true.
// 3. fields is not empty.
// 4. Cumulative parse_errors == 0.
// ---------------------------------------------------------------------------
#ifndef FN_TEST_REPO_ROOT
#define FN_TEST_REPO_ROOT ""
#endif
#include <filesystem>
TEST_CASE("parses_real_issues_golden") {
const std::string repo_root = FN_TEST_REPO_ROOT;
if (repo_root.empty()) {
WARN("FN_TEST_REPO_ROOT not set — skipping golden run");
return;
}
std::vector<std::filesystem::path> md_files;
for (const auto& dir : {"dev/issues", "dev/flows"}) {
const auto p = std::filesystem::path(repo_root) / dir;
if (!std::filesystem::is_directory(p)) continue;
for (const auto& entry : std::filesystem::directory_iterator(p)) {
if (entry.path().extension() == ".md")
md_files.push_back(entry.path());
}
}
REQUIRE_FALSE(md_files.empty());
int parse_errors = 0;
for (const auto& path : md_files) {
std::ifstream f(path);
REQUIRE(f.is_open());
std::ostringstream ss;
ss << f.rdbuf();
const std::string content = ss.str();
Frontmatter fm;
// Must not throw
REQUIRE_NOTHROW(fm = parse_md_frontmatter(content));
if (content.rfind("---", 0) == 0) {
// File starts with `---`: expect frontmatter was found
INFO("File: " << path.string());
REQUIRE(fm.has_frontmatter);
REQUIRE_FALSE(fm.fields.empty());
}
}
REQUIRE(parse_errors == 0);
}