diff --git a/playground/tables/CMakeLists.txt b/playground/tables/CMakeLists.txt index ffc7535..903dd22 100644 --- a/playground/tables/CMakeLists.txt +++ b/playground/tables/CMakeLists.txt @@ -3,13 +3,20 @@ add_imgui_app(tables_playground main.cpp data_table.cpp data_table_logic.cpp + lua_engine.cpp + tql.cpp + viz.cpp ) +target_link_libraries(tables_playground PRIVATE lua54 implot) -# Self-test E2E (logica pura, sin ImGui). No depende de fn_framework. +# Self-test E2E (logica pura + lua_engine + tql). add_executable(tables_playground_self_test self_test.cpp data_table_logic.cpp + lua_engine.cpp + tql.cpp ) target_include_directories(tables_playground_self_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ) +target_link_libraries(tables_playground_self_test PRIVATE lua54) diff --git a/playground/tables/data_table.cpp b/playground/tables/data_table.cpp index 0d0719d..14879d7 100644 --- a/playground/tables/data_table.cpp +++ b/playground/tables/data_table.cpp @@ -1,7 +1,15 @@ #include "data_table.h" +#include "app_base.h" #include "imgui.h" +#include "lua_engine.h" +#include "tql.h" +#include "viz.h" +#include +#include #include +#include +#include #include #include @@ -9,84 +17,1373 @@ namespace data_table { namespace { -// Estado UI por-celda/por-header — sobrevive entre frames pero NO se persiste -// a disco. Si se promueve al registry hay que pasarlo al State del caller. +// --------------------------------------------------------------------------- +// UI state global por-instancia (singleton playground). +// --------------------------------------------------------------------------- struct UiState { - int pending_col = -1; + int pending_col = -1; std::string pending_value; bool open_cell_popup = false; int header_popup_col = -1; - std::unordered_map filter_inputs; // col -> buffer - std::unordered_map color_value_inputs; // col -> buffer - std::unordered_map color_picker_vals; // col -> color + std::unordered_map filter_inputs; + std::unordered_map color_value_inputs; + std::unordered_map color_picker_vals; + + int addf_col = 0; + std::string addf_val; + bool addf_range = false; + std::string addf_lo; + std::string addf_hi; + + 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; + + std::string last_export_path; + + // Modal de columna custom (formula). + bool cf_open = false; + bool cf_editing = false; + int cf_edit_idx = -1; + int cf_target_stage = 0; // stage donde se guarda la formula + std::string cf_formula; + std::string cf_name; + ColumnType cf_type = ColumnType::String; + std::string cf_error; + + bool cf_ac_open = false; + int cf_ac_start = -1; + int cf_ac_cursor = -1; + std::string cf_ac_filter; + bool cf_force_cursor = false; + int cf_target_cursor = -1; + + // TQL modales. + bool tql_show_open = false; + std::string tql_show_text; + bool tql_apply_open = false; + std::string tql_apply_text; + std::string tql_apply_error; + char tql_file_path[256] = "table.tql"; + std::string tql_io_status; // mensaje "saved" / "loaded" / error + + // Add-breakout popup (stage > 0). + int brk_picker_col = 0; + + // Add-aggregation popup (stage > 0). + int agg_picker_fn = (int)AggFn::Count; + int agg_picker_col = 0; + double agg_picker_arg = 0.95; + + // Edit chip popups: click der sobre chip. + // 0=none, 1=filter, 2=breakout, 3=agg, 4=sort + int edit_chip_kind = 0; + int edit_chip_idx = -1; + int edit_col_idx = 0; // combo idx para col picker + int edit_op = (int)Op::Eq; + int edit_agg_fn = (int)AggFn::Count; + double edit_agg_arg = 0.5; + bool edit_sort_desc = false; + std::string edit_value; + + // Add-sort popup (any stage). + int sort_picker_col = 0; + bool sort_picker_desc = false; + + bool stats_mode = false; + std::vector stats_cache; + const char* const* last_cells = nullptr; + int last_rows = -1; + int last_eff_cols = -1; + size_t last_filter_h = (size_t)-1; + int last_visible = -1; + + // Snapshot del active stage output para el config popup. + std::vector active_headers; + std::vector active_types; + // Snapshot del INPUT del active stage (= output del previo o orig+derived + // si active==0). Para que el config popup pueda cambiar la categoria del + // breakout del stage activo eligiendo de las cols upstream. + std::vector input_headers_active; + std::vector input_types_active; + + // Para forzar re-fit en cambio de display/stage/config. + ViewMode prev_viz_display = ViewMode::Table; + int prev_viz_stage = 0; + size_t prev_viz_cfg_h = 0; + + // show_chrome user override. + bool chrome_user_set = false; + bool chrome_user_visible = true; + + // Toggle Table <-> View: remember last non-table display. + ViewMode last_non_table_main = ViewMode::Bar; }; UiState& ui() { static UiState s; return s; } -void ensure_init(State& st, int cols) { - if ((int)st.col_visible.size() != cols) st.col_visible.assign(cols, true); +int autocomplete_cb(ImGuiInputTextCallbackData* data) { + UiState* U = (UiState*)data->UserData; + if (data->EventFlag == ImGuiInputTextFlags_CallbackAlways) { + if (U->cf_force_cursor) { + data->CursorPos = U->cf_target_cursor; + U->cf_force_cursor = false; + } + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackEdit) { + std::string filter; + int idx = find_open_bracket(data->Buf, data->BufTextLen, + data->CursorPos, filter); + if (idx >= 0) { + U->cf_ac_open = true; + U->cf_ac_start = idx; + U->cf_ac_cursor = data->CursorPos; + U->cf_ac_filter = filter; + } else { + U->cf_ac_open = false; + } + } + return 0; } -void draw_chips(State& st, const char* const* headers, int cols) { - if (st.filters.empty()) { - ImGui::TextDisabled("Sin filtros. Click en celda -> elige operador."); +size_t filters_hash(const std::vector& f) { + size_t h = 0xcbf29ce484222325ULL; + for (const auto& x : f) { + h ^= (size_t)x.col; h *= 0x100000001b3ULL; + h ^= (size_t)x.op; h *= 0x100000001b3ULL; + for (char ch : x.value) { h ^= (size_t)(unsigned char)ch; h *= 0x100000001b3ULL; } + } + return h; +} + +void ensure_init(State& st, int eff_cols) { + if ((int)st.col_visible.size() < eff_cols) st.col_visible.resize(eff_cols, true); + if ((int)st.col_order.size() != eff_cols) { + std::vector next; + next.reserve(eff_cols); + for (int x : st.col_order) if (x >= 0 && x < eff_cols) next.push_back(x); + std::vector seen(eff_cols, false); + for (int x : next) seen[x] = true; + for (int i = 0; i < eff_cols; ++i) if (!seen[i]) next.push_back(i); + st.col_order = std::move(next); + } + if ((int)st.col_visible.size() < (int)st.col_order.size()) + st.col_visible.resize(st.col_order.size(), true); +} + +// --------------------------------------------------------------------------- +// Breadcrumb stages: fila de botones Raw > Stage 1 > Stage 2 ... [+ Stage] +// --------------------------------------------------------------------------- +void draw_stage_breadcrumb(State& st) { + st.ensure_stage0(); + 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; + } +} + +struct ColInfo { std::string name; ColumnType type; }; +std::vector collect_active_col_info(const State& st); + +// Auto-promote: si user en stage 0 elige una viz que necesita agrupacion, +// crea stage 1 con breakout=primera cat + agg=sum(primera num) o count. +void auto_promote_aggregated(State& st) { + auto& U = ui(); + if (st.active_stage != 0) return; + if (st.stages.size() != 1) return; + + std::string cat_name; + std::string num_name; + for (size_t i = 0; i < U.active_headers.size() && i < U.active_types.size(); ++i) { + ColumnType t = U.active_types[i]; + if (cat_name.empty() && + (t == ColumnType::String || t == ColumnType::Date || + t == ColumnType::Bool || t == ColumnType::Json)) { + cat_name = U.active_headers[i]; + } + if (num_name.empty() && + (t == ColumnType::Int || t == ColumnType::Float)) { + num_name = U.active_headers[i]; + } + } + + Stage s1; + if (!cat_name.empty()) s1.breakouts.push_back(cat_name); + Aggregation a; + if (!num_name.empty()) { + a.fn = AggFn::Sum; + a.col = num_name; + } else { + a.fn = AggFn::Count; + } + s1.aggregations.push_back(a); + st.stages.push_back(std::move(s1)); + st.active_stage = (int)st.stages.size() - 1; +} + +// Toggle simple: un solo boton que alterna entre Table y el last_non_table. +// Para el main pasa st (para poder auto-promote a stage agregado si la viz +// destino lo requiere). Para extras usar overload sin State. +void draw_table_toggle(ViewMode& display, ViewMode& last_non_table, + const char* id_suffix, State* st_opt = nullptr) { + 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); +} + +// Render extra viz panel: child window con toolbar mini + chart. +// Devuelve true si user pidio cerrar. +bool draw_extra_panel(State& st, VizPanel& p, int idx, const StageOutput& so) { + 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]; + ImGui::TextUnformatted(s ? s : ""); + } + } + ImGui::EndTable(); + } + } else { + viz::render(so, p.display, p.config, ImVec2(-1, -1)); + } + + ImGui::EndChild(); + (void)st; + return close_req; +} + +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 all_names; + std::vector num_names; + std::vector 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& 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(); + // Show checkbox for each numeric col + 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 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 r = (unsigned int)(col_f[0] * 255); + unsigned int g = (unsigned int)(col_f[1] * 255); + unsigned int b = (unsigned int)(col_f[2] * 255); + unsigned int a = (unsigned int)(col_f[3] * 255); + vc.primary_color = (a << 24) | (b << 16) | (g << 8) | r; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Auto##color")) vc.primary_color = 0; + + // Hist bins + if (m == ViewMode::Histogram || m == ViewMode::Histogram2D) { + ImGui::SetNextItemWidth(120); + int b = vc.hist_bins; + if (ImGui::InputInt("Bins (0=auto)", &b)) { + if (b < 0) b = 0; + vc.hist_bins = b; + } + } + + // Pie radius + if (m == ViewMode::Pie || m == ViewMode::Donut) { + ImGui::SetNextItemWidth(120); + float r = vc.pie_radius; + if (ImGui::SliderFloat("Radius (0=auto)", &r, 0.0f, 0.5f, "%.2f")) { + vc.pie_radius = r; + } + } + + // 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(); +} + +// Devuelve nombres + tipos del active stage output snapshot (poblado por render). +std::vector collect_active_col_info(const State& st) { + (void)st; + auto& U = ui(); + std::vector r; + int n = (int)std::min(U.active_headers.size(), U.active_types.size()); + r.reserve(n); + for (int i = 0; i < n; ++i) r.push_back({U.active_headers[i], U.active_types[i]}); + return r; +} + +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] [+ 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("+ 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(); +} + +// --------------------------------------------------------------------------- +// Join chips (fase 9 — solo visible si hay joinables). +// --------------------------------------------------------------------------- +void draw_joins_chips(State& st, const std::vector& joinables, + const std::vector& main_headers) { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 80, 130, 90, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(100, 160, 110, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 60, 110, 70, 220)); + ImGui::TextDisabled("Joins:"); + ImGui::SameLine(); + + int remove_idx = -1; + for (size_t i = 0; i < st.joins.size(); ++i) { + const auto& jn = st.joins[i]; + char lbl[256]; + std::string on_str; + for (size_t k = 0; k < jn.on.size(); ++k) { + if (k) on_str += ","; + on_str += jn.on[k].first + "=" + jn.on[k].second; + } + std::snprintf(lbl, sizeof(lbl), "%s <- %s on %s (%s)##join_%zu", + jn.alias.empty() ? "_" : jn.alias.c_str(), + jn.source.c_str(), + on_str.c_str(), + join_strategy_label(jn.strategy), + i); + ImGui::Button(lbl); + ImGui::SameLine(); + char xlbl[32]; std::snprintf(xlbl, sizeof(xlbl), "x##rm_join_%zu", i); + if (ImGui::SmallButton(xlbl)) remove_idx = (int)i; + ImGui::SameLine(); + } + if (remove_idx >= 0) st.joins.erase(st.joins.begin() + remove_idx); + + if (ImGui::SmallButton("+##add_join")) { + ImGui::OpenPopup("##add_join_popup"); + } + ImGui::PopStyleColor(3); + + // Popup add + static int pick_source_idx = 0; + static char pick_alias[64] = ""; + static int pick_strategy = 0; + static int pick_left_col = 0; + static int pick_right_col = 0; + if (ImGui::BeginPopup("##add_join_popup")) { + ImGui::Text("Add join"); + ImGui::SetNextItemWidth(180); + if (ImGui::BeginCombo("source", joinables[pick_source_idx].name.c_str())) { + for (int k = 0; k < (int)joinables.size(); ++k) { + bool sel = (k == pick_source_idx); + if (ImGui::Selectable(joinables[k].name.c_str(), sel)) { + pick_source_idx = k; + pick_right_col = 0; + if (pick_alias[0] == 0) + std::snprintf(pick_alias, sizeof(pick_alias), "%s", joinables[k].name.c_str()); + } + } + ImGui::EndCombo(); + } + ImGui::SetNextItemWidth(180); + ImGui::InputText("alias", pick_alias, sizeof(pick_alias)); + + const char* strategies[] = {"left", "inner", "right", "full"}; + ImGui::SetNextItemWidth(120); + ImGui::Combo("strategy", &pick_strategy, strategies, IM_ARRAYSIZE(strategies)); + + // left col combo (de main_headers) + ImGui::SetNextItemWidth(180); + const char* lcur = (pick_left_col >= 0 && pick_left_col < (int)main_headers.size()) + ? main_headers[pick_left_col].c_str() : "?"; + if (ImGui::BeginCombo("left col", lcur)) { + for (int k = 0; k < (int)main_headers.size(); ++k) { + bool sel = (k == pick_left_col); + if (ImGui::Selectable(main_headers[k].c_str(), sel)) pick_left_col = k; + } + ImGui::EndCombo(); + } + + // right col combo (de joinables[pick_source_idx].headers) + const TableInput& src = joinables[pick_source_idx]; + const char* rcur = (pick_right_col >= 0 && pick_right_col < (int)src.headers.size()) + ? src.headers[pick_right_col].c_str() : "?"; + ImGui::SetNextItemWidth(180); + if (ImGui::BeginCombo("right col", rcur)) { + for (int k = 0; k < (int)src.headers.size(); ++k) { + bool sel = (k == pick_right_col); + if (ImGui::Selectable(src.headers[k].c_str(), sel)) pick_right_col = k; + } + ImGui::EndCombo(); + } + + ImGui::Separator(); + if (ImGui::SmallButton("Add")) { + Join jn; + jn.alias = pick_alias; + jn.source = src.name; + jn.on.push_back({main_headers[pick_left_col], src.headers[pick_right_col]}); + jn.strategy = (JoinStrategy)pick_strategy; + st.joins.push_back(jn); + pick_alias[0] = 0; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Cancel")) ImGui::CloseCurrentPopup(); + + ImGui::EndPopup(); + } + ImGui::NewLine(); +} + +// --------------------------------------------------------------------------- +// Filter chips para el stage activo. eff_headers/eff_cols son del INPUT del +// stage activo (= orig+derived para stage 0; output del stage previo para 1+). +// --------------------------------------------------------------------------- +void draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols) { + auto& U = ui(); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(120, 60, 170, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(150, 85, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 95, 45, 140, 240)); + if (ImGui::SmallButton("+##addfilter_btn")) ImGui::OpenPopup("##addfilter"); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + if (stg.filters.empty()) { + ImGui::TextDisabled("Sin filtros."); return; } - for (size_t i = 0; i < st.filters.size(); ) { - const auto& f = st.filters[i]; - const char* hdr = (f.col >= 0 && f.col < cols) ? headers[f.col] : "?"; + for (size_t i = 0; i < stg.filters.size(); ) { + const auto& f = stg.filters[i]; + const char* hdr = (f.col >= 0 && f.col < eff_cols) ? eff_headers[f.col] : "?"; char buf[256]; std::snprintf(buf, sizeof(buf), "%s %s %s x##chip%zu", hdr, op_label(f.op), f.value.c_str(), i); - ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(60, 100, 160, 220)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(80, 130, 200, 240)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(45, 80, 130, 240)); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(120, 60, 170, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(150, 85, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 95, 45, 140, 240)); bool clicked = ImGui::SmallButton(buf); ImGui::PopStyleColor(3); - if (clicked) { st.filters.erase(st.filters.begin() + i); continue; } + // Click derecho: edit + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.edit_chip_kind = 1; + U.edit_chip_idx = (int)i; + U.edit_col_idx = f.col; + U.edit_op = (int)f.op; + U.edit_value = f.value; + ImGui::OpenPopup("##edit_filter"); + } + if (clicked) { stg.filters.erase(stg.filters.begin() + i); continue; } ImGui::SameLine(); ++i; } ImGui::NewLine(); } -// Devuelve true y rellena out si el usuario eligio un operador. -bool draw_op_menu_items(Op& out) { - const Op ops[] = {Op::Eq, Op::Neq, Op::Gt, Op::Gte, Op::Lt, Op::Lte}; - for (Op o : ops) { - if (ImGui::MenuItem(op_label(o))) { out = o; return true; } +// Chips de breakout (stage > 0). +void draw_breakout_chips(Stage& stg, const char* const* in_headers, int in_cols) { + auto& U = ui(); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 60, 160, 170, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 80, 190, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 40, 130, 140, 240)); + if (ImGui::SmallButton("+##addbreakout_btn")) ImGui::OpenPopup("##addbreakout"); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + if (stg.breakouts.empty()) { + ImGui::TextDisabled("Group by: ninguna col."); + return; + } + for (size_t i = 0; i < stg.breakouts.size(); ) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s x##bk%zu", stg.breakouts[i].c_str(), i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 60, 160, 170, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 80, 190, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 40, 130, 140, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.edit_chip_kind = 2; + U.edit_chip_idx = (int)i; + // resolve current col name to index in in_headers + U.edit_col_idx = 0; + for (int c = 0; c < in_cols; ++c) { + if (std::strcmp(in_headers[c], stg.breakouts[i].c_str()) == 0) { + U.edit_col_idx = c; break; + } + } + ImGui::OpenPopup("##edit_breakout"); + } + if (clicked) { stg.breakouts.erase(stg.breakouts.begin() + i); continue; } + ImGui::SameLine(); + ++i; + } + (void)in_headers; (void)in_cols; + ImGui::NewLine(); +} + +const char* agg_fn_label(AggFn f) { + switch (f) { + case AggFn::Count: return "count"; + case AggFn::Sum: return "sum"; + case AggFn::Avg: return "avg"; + case AggFn::Min: return "min"; + case AggFn::Max: return "max"; + case AggFn::Distinct: return "distinct"; + case AggFn::Stddev: return "stddev"; + case AggFn::Median: return "median"; + case AggFn::P25: return "p25"; + case AggFn::P75: return "p75"; + case AggFn::P90: return "p90"; + case AggFn::P99: return "p99"; + case AggFn::Percentile: return "percentile"; + } + return "?"; +} + +void draw_aggregation_chips(Stage& stg, const char* const* in_headers, int in_cols) { + auto& U = ui(); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 40, 140, 60, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 60, 170, 85, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 30, 110, 45, 240)); + if (ImGui::SmallButton("+##addagg_btn")) ImGui::OpenPopup("##addagg"); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + if (stg.aggregations.empty()) { + ImGui::TextDisabled("Aggregations: ninguna."); + return; + } + for (size_t i = 0; i < stg.aggregations.size(); ) { + const auto& a = stg.aggregations[i]; + char buf[256]; + if (a.fn == AggFn::Count) { + std::snprintf(buf, sizeof(buf), "count x##ag%zu", i); + } else if (a.fn == AggFn::Percentile) { + std::snprintf(buf, sizeof(buf), "percentile(%s, %g) x##ag%zu", + a.col.c_str(), a.arg, i); + } else { + std::snprintf(buf, sizeof(buf), "%s(%s) x##ag%zu", + agg_fn_label(a.fn), a.col.c_str(), i); + } + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 40, 140, 60, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 60, 170, 85, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 30, 110, 45, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.edit_chip_kind = 3; + U.edit_chip_idx = (int)i; + U.edit_agg_fn = (int)a.fn; + U.edit_agg_arg = a.arg; + U.edit_col_idx = 0; + for (int c = 0; c < in_cols; ++c) { + if (std::strcmp(in_headers[c], a.col.c_str()) == 0) { + U.edit_col_idx = c; break; + } + } + ImGui::OpenPopup("##edit_agg"); + } + if (clicked) { stg.aggregations.erase(stg.aggregations.begin() + i); continue; } + ImGui::SameLine(); + ++i; + } + (void)in_headers; (void)in_cols; + ImGui::NewLine(); +} + +void draw_sort_chips(Stage& stg) { + auto& U = ui(); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(220, 130, 50, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(240, 155, 75, 245)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(180, 100, 30, 240)); + if (ImGui::SmallButton("+##addsort_btn")) ImGui::OpenPopup("##addsort"); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + if (stg.sorts.empty()) { + ImGui::TextDisabled("Sort: ninguno."); + return; + } + int erase_idx = -1; + int drag_src = -1; + int drag_dst = -1; + for (size_t i = 0; i < stg.sorts.size(); ++i) { + const auto& sc = stg.sorts[i]; + char buf[256]; + std::snprintf(buf, sizeof(buf), "%zu. %s %s x##srt%zu", + i + 1, sc.col.c_str(), sc.desc ? "desc" : "asc", i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(220, 130, 50, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(240, 155, 75, 245)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(180, 100, 30, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + + // Drag source: prioridad multi-sort reorderable. + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + int idx = (int)i; + ImGui::SetDragDropPayload("##sortreorder", &idx, sizeof(int)); + ImGui::Text("Move sort #%zu", i + 1); + ImGui::EndDragDropSource(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("##sortreorder")) { + drag_src = *(const int*)p->Data; + drag_dst = (int)i; + } + ImGui::EndDragDropTarget(); + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.edit_chip_kind = 4; + U.edit_chip_idx = (int)i; + U.edit_value = sc.col; + U.edit_sort_desc = sc.desc; + ImGui::OpenPopup("##edit_sort"); + } + if (clicked) erase_idx = (int)i; + ImGui::SameLine(); + } + ImGui::NewLine(); + + if (drag_src >= 0 && drag_dst >= 0 && drag_src != drag_dst && + drag_src < (int)stg.sorts.size() && drag_dst < (int)stg.sorts.size()) + { + SortClause moved = std::move(stg.sorts[drag_src]); + stg.sorts.erase(stg.sorts.begin() + drag_src); + int insert_at = (drag_src < drag_dst) ? drag_dst : drag_dst; + if (insert_at > (int)stg.sorts.size()) insert_at = (int)stg.sorts.size(); + stg.sorts.insert(stg.sorts.begin() + insert_at, std::move(moved)); + } else if (erase_idx >= 0 && erase_idx < (int)stg.sorts.size()) { + stg.sorts.erase(stg.sorts.begin() + erase_idx); + } +} + +// ---- Edit chip popups: click derecho sobre chip abre popup. ---- +// Header click handler: +// click: si col ya esta en sorts -> cicla su direccion asc/desc/off. +// sino -> append {col, asc} al final (multi-sort por defecto). +// shift+click: reset. Reemplaza sorts con {col, asc} (sort unico). +void apply_header_sort_click(Stage& stg, const std::string& col_name, bool shift) { + if (shift) { + stg.sorts.clear(); + stg.sorts.push_back({col_name, false}); + return; + } + int idx = -1; + for (size_t i = 0; i < stg.sorts.size(); ++i) { + if (stg.sorts[i].col == col_name) { idx = (int)i; break; } + } + if (idx < 0) { + stg.sorts.push_back({col_name, false}); + } else { + if (!stg.sorts[idx].desc) stg.sorts[idx].desc = true; + else stg.sorts.erase(stg.sorts.begin() + idx); + } +} + +void draw_edit_filter_popup(Stage& stg, const char* const* headers, int n_cols, + const std::vector& types) { + auto& U = ui(); + if (!ImGui::BeginPopup("##edit_filter")) return; + if (U.edit_chip_idx >= 0 && U.edit_chip_idx < (int)stg.filters.size()) { + auto& f = stg.filters[U.edit_chip_idx]; + ImGui::SetNextItemWidth(200); + const char* cur = (U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + ? headers[U.edit_col_idx] : "?"; + if (ImGui::BeginCombo("col", cur)) { + for (int c = 0; c < n_cols; ++c) { + bool sel = (U.edit_col_idx == c); + if (ImGui::Selectable(headers[c], sel)) U.edit_col_idx = c; + } + ImGui::EndCombo(); + } + ColumnType t = (U.edit_col_idx >= 0 && U.edit_col_idx < (int)types.size()) + ? types[U.edit_col_idx] : ColumnType::String; + auto ops = ops_for_type(t); + ImGui::SetNextItemWidth(140); + if (ImGui::BeginCombo("op", op_label((Op)U.edit_op))) { + for (auto o : ops) { + bool sel = ((int)o == U.edit_op); + if (ImGui::Selectable(op_label(o), sel)) U.edit_op = (int)o; + } + ImGui::EndCombo(); + } + char vbuf[256] = {0}; + std::snprintf(vbuf, sizeof(vbuf), "%s", U.edit_value.c_str()); + ImGui::SetNextItemWidth(220); + if (ImGui::InputText("value", vbuf, sizeof(vbuf))) U.edit_value = vbuf; + if (ImGui::Button("Save")) { + f.col = U.edit_col_idx; + f.op = (Op)U.edit_op; + f.value = U.edit_value; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_edit_breakout_popup(Stage& stg, const char* const* headers, int n_cols) { + auto& U = ui(); + if (!ImGui::BeginPopup("##edit_breakout")) return; + if (U.edit_chip_idx >= 0 && U.edit_chip_idx < (int)stg.breakouts.size()) { + ImGui::SetNextItemWidth(240); + const char* cur = (U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + ? headers[U.edit_col_idx] : "?"; + if (ImGui::BeginCombo("col", cur)) { + for (int c = 0; c < n_cols; ++c) { + bool sel = (U.edit_col_idx == c); + if (ImGui::Selectable(headers[c], sel)) U.edit_col_idx = c; + } + ImGui::EndCombo(); + } + if (ImGui::Button("Save")) { + if (U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + stg.breakouts[U.edit_chip_idx] = headers[U.edit_col_idx]; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_edit_agg_popup(Stage& stg, const char* const* headers, int n_cols) { + auto& U = ui(); + if (!ImGui::BeginPopup("##edit_agg")) return; + if (U.edit_chip_idx >= 0 && U.edit_chip_idx < (int)stg.aggregations.size()) { + const AggFn all_fns[] = {AggFn::Count, AggFn::Sum, AggFn::Avg, AggFn::Min, AggFn::Max, + AggFn::Distinct, AggFn::Stddev, AggFn::Median, + AggFn::P25, AggFn::P75, AggFn::P90, AggFn::P99, + AggFn::Percentile}; + ImGui::SetNextItemWidth(160); + if (ImGui::BeginCombo("fn", agg_fn_label((AggFn)U.edit_agg_fn))) { + for (auto f : all_fns) { + bool sel = ((int)f == U.edit_agg_fn); + if (ImGui::Selectable(agg_fn_label(f), sel)) U.edit_agg_fn = (int)f; + } + ImGui::EndCombo(); + } + if ((AggFn)U.edit_agg_fn != AggFn::Count) { + ImGui::SetNextItemWidth(200); + const char* cur = (U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + ? headers[U.edit_col_idx] : "?"; + if (ImGui::BeginCombo("col", cur)) { + for (int c = 0; c < n_cols; ++c) { + bool sel = (U.edit_col_idx == c); + if (ImGui::Selectable(headers[c], sel)) U.edit_col_idx = c; + } + ImGui::EndCombo(); + } + } + if ((AggFn)U.edit_agg_fn == AggFn::Percentile) { + float v = (float)U.edit_agg_arg; + ImGui::SetNextItemWidth(140); + if (ImGui::InputFloat("p (0..1)", &v, 0.05f, 0.1f, "%.2f")) + U.edit_agg_arg = v; + } + if (ImGui::Button("Save")) { + auto& a = stg.aggregations[U.edit_chip_idx]; + a.fn = (AggFn)U.edit_agg_fn; + if (a.fn != AggFn::Count && U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + a.col = headers[U.edit_col_idx]; + else if (a.fn == AggFn::Count) a.col.clear(); + a.arg = U.edit_agg_arg; + a.alias.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_edit_sort_popup(Stage& stg, const char* const* headers, int n_cols) { + auto& U = ui(); + if (!ImGui::BeginPopup("##edit_sort")) return; + if (U.edit_chip_idx >= 0 && U.edit_chip_idx < (int)stg.sorts.size()) { + ImGui::SetNextItemWidth(240); + if (ImGui::BeginCombo("col", U.edit_value.c_str())) { + for (int c = 0; c < n_cols; ++c) { + bool sel = (U.edit_value == headers[c]); + if (ImGui::Selectable(headers[c], sel)) U.edit_value = headers[c]; + } + ImGui::EndCombo(); + } + if (ImGui::RadioButton("asc", !U.edit_sort_desc)) U.edit_sort_desc = false; + ImGui::SameLine(); + if (ImGui::RadioButton("desc", U.edit_sort_desc)) U.edit_sort_desc = true; + if (ImGui::Button("Save")) { + auto& sc = stg.sorts[U.edit_chip_idx]; + sc.col = U.edit_value; + sc.desc = U.edit_sort_desc; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void maybe_recompute_stats(const char* const* cells, int rows, int orig_cols, + int eff_cols, const std::vector& filters, + const std::vector& visible, + const std::vector& src_for_eff) +{ + auto& U = ui(); + if (!U.stats_mode) return; + size_t fh = filters_hash(filters); + bool ds_changed = (cells != U.last_cells || rows != U.last_rows || + eff_cols != U.last_eff_cols || + (int)U.stats_cache.size() != eff_cols); + bool fl_changed = (fh != U.last_filter_h || (int)visible.size() != U.last_visible); + if (!ds_changed && !fl_changed) return; + U.stats_cache.resize(eff_cols); + const int* idx = visible.empty() ? nullptr : visible.data(); + int n = (int)visible.size(); + for (int c = 0; c < eff_cols; ++c) { + int src = src_for_eff[c]; + U.stats_cache[c] = compute_column_stats(cells, rows, orig_cols, src, + 100000, idx, n); + } + U.last_cells = cells; + U.last_rows = rows; + U.last_eff_cols = eff_cols; + U.last_filter_h = fh; + U.last_visible = (int)visible.size(); +} + +bool draw_typed_ops(ColumnType t, Op& out) { + auto ops = ops_for_type(t); + for (size_t i = 0; i < ops.size(); ++i) { + if (i % 5 != 0) ImGui::SameLine(); + if (ImGui::SmallButton(op_label(ops[i]))) { out = ops[i]; return true; } } return false; } -void draw_header_menu(State& st, int col, const char* const* headers, int col_count) { +bool type_supports_range(ColumnType t) { + return t == ColumnType::Int || t == ColumnType::Float || t == ColumnType::Date; +} + +void draw_add_filter_popup(Stage& stg, const char* const* eff_headers_arr, int eff_cols, + const std::vector& eff_types) +{ auto& U = ui(); + if (!ImGui::BeginPopup("##addfilter")) return; + if (U.addf_col < 0 || U.addf_col >= eff_cols) U.addf_col = 0; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("col", eff_headers_arr[U.addf_col])) { + for (int c = 0; c < eff_cols; ++c) { + char it[160]; + std::snprintf(it, sizeof(it), "%s %s", + column_type_icon(eff_types[c]), eff_headers_arr[c]); + bool sel = (U.addf_col == c); + if (ImGui::Selectable(it, sel)) U.addf_col = c; + } + ImGui::EndCombo(); + } + ColumnType t = eff_types[U.addf_col]; + ImGui::TextDisabled("type: %s %s", column_type_icon(t), column_type_name(t)); + + bool can_range = type_supports_range(t); + if (can_range) ImGui::Checkbox("Range (min/max)", &U.addf_range); + else U.addf_range = false; + + if (!U.addf_range) { + char buf[256] = {0}; + std::snprintf(buf, sizeof(buf), "%s", U.addf_val.c_str()); + ImGui::SetNextItemWidth(220); + if (ImGui::InputText("val", buf, sizeof(buf))) U.addf_val = buf; + Op picked; + if (draw_typed_ops(t, picked)) { + stg.filters.push_back({U.addf_col, picked, U.addf_val}); + U.addf_val.clear(); + ImGui::CloseCurrentPopup(); + } + } else { + char lo[128] = {0}, hi[128] = {0}; + std::snprintf(lo, sizeof(lo), "%s", U.addf_lo.c_str()); + std::snprintf(hi, sizeof(hi), "%s", U.addf_hi.c_str()); + ImGui::SetNextItemWidth(100); + if (ImGui::InputText("min", lo, sizeof(lo))) U.addf_lo = lo; + ImGui::SameLine(); + ImGui::SetNextItemWidth(100); + if (ImGui::InputText("max", hi, sizeof(hi))) U.addf_hi = hi; + ImGui::SameLine(); + if (ImGui::SmallButton("Add range")) { + if (!U.addf_lo.empty()) stg.filters.push_back({U.addf_col, Op::Gte, U.addf_lo}); + if (!U.addf_hi.empty()) stg.filters.push_back({U.addf_col, Op::Lte, U.addf_hi}); + U.addf_lo.clear(); U.addf_hi.clear(); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); +} + +void draw_add_breakout_popup(Stage& stg, const char* const* in_headers, int in_cols, + const std::vector& in_types) { + auto& U = ui(); + if (!ImGui::BeginPopup("##addbreakout")) return; + if (U.brk_picker_col < 0 || U.brk_picker_col >= in_cols) U.brk_picker_col = 0; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("col##bkcol", in_headers[U.brk_picker_col])) { + for (int c = 0; c < in_cols; ++c) { + char it[160]; + std::snprintf(it, sizeof(it), "%s %s", + column_type_icon(in_types[c]), in_headers[c]); + bool sel = (U.brk_picker_col == c); + if (ImGui::Selectable(it, sel)) U.brk_picker_col = c; + } + ImGui::EndCombo(); + } + if (ImGui::Button("Add##bk")) { + stg.breakouts.emplace_back(in_headers[U.brk_picker_col]); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_add_aggregation_popup(Stage& stg, const char* const* in_headers, int in_cols, + const std::vector& in_types) { + auto& U = ui(); + if (!ImGui::BeginPopup("##addagg")) return; + + AggFn cur_fn = (AggFn)U.agg_picker_fn; + ImGui::SetNextItemWidth(160); + if (ImGui::BeginCombo("fn##aggfn", agg_fn_label(cur_fn))) { + AggFn all[] = {AggFn::Count, AggFn::Sum, AggFn::Avg, AggFn::Min, AggFn::Max, + AggFn::Distinct, AggFn::Stddev, AggFn::Median, + AggFn::P25, AggFn::P75, AggFn::P90, AggFn::P99, + AggFn::Percentile}; + for (AggFn f : all) { + bool sel = (f == cur_fn); + if (ImGui::Selectable(agg_fn_label(f), sel)) U.agg_picker_fn = (int)f; + } + ImGui::EndCombo(); + } + if (cur_fn != AggFn::Count) { + if (U.agg_picker_col < 0 || U.agg_picker_col >= in_cols) U.agg_picker_col = 0; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("col##aggcol", in_headers[U.agg_picker_col])) { + for (int c = 0; c < in_cols; ++c) { + char it[160]; + std::snprintf(it, sizeof(it), "%s %s", + column_type_icon(in_types[c]), in_headers[c]); + bool sel = (U.agg_picker_col == c); + if (ImGui::Selectable(it, sel)) U.agg_picker_col = c; + } + ImGui::EndCombo(); + } + } + if (cur_fn == AggFn::Percentile) { + double v = U.agg_picker_arg; + ImGui::SetNextItemWidth(120); + if (ImGui::InputDouble("p (0..1)", &v, 0.05, 0.1, "%.2f")) { + if (v < 0) v = 0; if (v > 1) v = 1; + U.agg_picker_arg = v; + } + } + if (ImGui::Button("Add##ag")) { + Aggregation a; + a.fn = cur_fn; + a.col = (cur_fn == AggFn::Count) ? "" : std::string(in_headers[U.agg_picker_col]); + a.arg = (cur_fn == AggFn::Percentile) ? U.agg_picker_arg : 0.0; + stg.aggregations.push_back(a); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_add_sort_popup(Stage& stg, const char* const* in_headers, int in_cols, + const std::vector& in_types) { + auto& U = ui(); + if (!ImGui::BeginPopup("##addsort")) return; + if (U.sort_picker_col < 0 || U.sort_picker_col >= in_cols) U.sort_picker_col = 0; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("col##sortcol", in_headers[U.sort_picker_col])) { + for (int c = 0; c < in_cols; ++c) { + char it[160]; + std::snprintf(it, sizeof(it), "%s %s", + column_type_icon(in_types[c]), in_headers[c]); + bool sel = (U.sort_picker_col == c); + if (ImGui::Selectable(it, sel)) U.sort_picker_col = c; + } + ImGui::EndCombo(); + } + ImGui::Checkbox("desc", &U.sort_picker_desc); + if (ImGui::Button("Add##srt")) { + SortClause sc; + sc.col = in_headers[U.sort_picker_col]; + sc.desc = U.sort_picker_desc; + stg.sorts.push_back(sc); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_header_menu(State& st, Stage& stg, int col, + const char* const* eff_headers_arr, int eff_cols, + const std::vector& eff_types, + int orig_cols, bool is_raw_stage) +{ + auto& U = ui(); + ColumnType t = eff_types[col]; + + if (ImGui::MenuItem("Sort ascending")) { + stg.sorts.clear(); + stg.sorts.push_back({eff_headers_arr[col], false}); + } + if (ImGui::MenuItem("Sort descending")) { + stg.sorts.clear(); + stg.sorts.push_back({eff_headers_arr[col], true}); + } + if (!stg.sorts.empty() && ImGui::MenuItem("Clear sort")) stg.sorts.clear(); + ImGui::Separator(); + auto& fbuf = U.filter_inputs[col]; fbuf.resize(256, '\0'); - if (ImGui::BeginMenu("Filter...")) { - ImGui::SetNextItemWidth(180); + ImGui::SetNextItemWidth(220); ImGui::InputText("##filterval", fbuf.data(), fbuf.size()); std::string val(fbuf.c_str()); - const Op ops[] = {Op::Eq, Op::Neq, Op::Gt, Op::Gte, Op::Lt, Op::Lte}; - for (size_t i = 0; i < sizeof(ops)/sizeof(ops[0]); ++i) { - if (i > 0) ImGui::SameLine(); + auto ops = ops_for_type(t); + for (size_t i = 0; i < ops.size(); ++i) { + if (i % 5 != 0) ImGui::SameLine(); if (ImGui::SmallButton(op_label(ops[i]))) { - st.filters.push_back({col, ops[i], val}); + stg.filters.push_back({col, ops[i], val}); ImGui::CloseCurrentPopup(); } } ImGui::EndMenu(); } + // Change type / derived solo en stage 0. + if (is_raw_stage) { + if (ImGui::BeginMenu("Change type")) { + const ColumnType types[] = { + ColumnType::String, ColumnType::Int, ColumnType::Float, + ColumnType::Bool, ColumnType::Date, ColumnType::Json + }; + for (auto nt : types) { + char lab[64]; + std::snprintf(lab, sizeof(lab), "%s %s", + column_type_icon(nt), column_type_name(nt)); + if (ImGui::MenuItem(lab)) { + DerivedColumn d; + d.source_col = (col < orig_cols) ? col : stg.derived[col - orig_cols].source_col; + d.type = nt; + d.name = std::string(eff_headers_arr[col]) + "_" + column_type_name(nt); + stg.derived.push_back(d); + } + } + ImGui::EndMenu(); + } + } + if (ImGui::BeginMenu("Conditional color")) { auto& vbuf = U.color_value_inputs[col]; vbuf.resize(256, '\0'); - auto it = U.color_picker_vals.find(col); - if (it == U.color_picker_vals.end()) { + if (U.color_picker_vals.find(col) == U.color_picker_vals.end()) U.color_picker_vals[col] = ImVec4(0.85f, 0.40f, 0.30f, 0.60f); - } ImVec4& cv = U.color_picker_vals[col]; ImGui::SetNextItemWidth(180); ImGui::InputText("equals", vbuf.data(), vbuf.size()); @@ -106,136 +1403,1576 @@ void draw_header_menu(State& st, int col, const char* const* headers, int col_co ImGui::EndMenu(); } - if (ImGui::MenuItem("Hide column")) { - st.col_visible[col] = false; + if (ImGui::MenuItem("Hide column")) st.col_visible[col] = false; + + if (is_raw_stage && col >= orig_cols && ImGui::MenuItem("Remove derived column")) { + int k = col - orig_cols; + stg.derived.erase(stg.derived.begin() + k); } ImGui::Separator(); if (ImGui::BeginMenu("Columns")) { - for (int k = 0; k < col_count; ++k) { + for (int k = 0; k < eff_cols; ++k) { bool v = st.col_visible[k]; - if (ImGui::Checkbox(headers[k], &v)) st.col_visible[k] = v; + char lab[160]; + std::snprintf(lab, sizeof(lab), "%s %s", + column_type_icon(eff_types[k]), eff_headers_arr[k]); + if (ImGui::Checkbox(lab, &v)) st.col_visible[k] = v; } if (ImGui::MenuItem("Show all")) { - for (int k = 0; k < col_count; ++k) st.col_visible[k] = true; + for (int k = 0; k < eff_cols; ++k) st.col_visible[k] = true; } ImGui::EndMenu(); } } -} // namespace +// --------------------------------------------------------------------------- +// Drill-down: anade un filter al stage previo y cambia active a stage previo. +// `col_name` y `value` se aplican como un Filter Op::Eq sobre el stage N-1. +// --------------------------------------------------------------------------- +void drill_into(State& st, int from_stage, + const std::string& col_name, const std::string& value, + const std::vector& 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; + st.stages[target].filters.push_back(make_drill_filter(ci, value)); + st.active_stage = target; +} + +} // anon namespace void render(const char* id, - const char* const* headers, - int col_count, - const char* const* cells, - int row_count, - State& st) + const std::vector& tables, + State& st, + bool show_chrome) { - ensure_init(st, col_count); - auto& U = ui(); + if (tables.empty()) return; + int main_idx = resolve_main_idx(tables, st.main_source); + if (main_idx < 0) return; - draw_chips(st, headers, col_count); + // Construir headers ptrs desde main table. + const TableInput& main_t = tables[(size_t)main_idx]; + static thread_local std::vector main_hdr_ptrs; + main_hdr_ptrs.clear(); + main_hdr_ptrs.reserve(main_t.cols); + for (int c = 0; c < main_t.cols; ++c) main_hdr_ptrs.push_back(main_t.headers[c].c_str()); + const char* const* headers_in = main_hdr_ptrs.data(); + int col_count = main_t.cols; + const char* const* cells_in = main_t.cells; + int row_count_in = main_t.rows; + const ColumnType* declared_types_in = main_t.types.data(); - auto visible_rows = compute_visible_rows(cells, row_count, col_count, st); - int visible_cols = 0; - for (bool v : st.col_visible) if (v) ++visible_cols; - - ImGui::Text("Filas: %d / %d Columnas: %d / %d", - (int)visible_rows.size(), row_count, visible_cols, col_count); - - if (visible_cols == 0) { - ImGui::TextDisabled("(todas las columnas ocultas - click derecho en cabecera anterior)"); - return; + // Joinables = todas las demas tablas. + static thread_local std::vector joinables_v; + joinables_v.clear(); + for (int i = 0; i < (int)tables.size(); ++i) { + if (i != main_idx) joinables_v.push_back(tables[(size_t)i]); } + const std::vector* joinables = joinables_v.empty() ? nullptr : &joinables_v; - ImGuiTableFlags flags = - ImGuiTableFlags_Borders | - ImGuiTableFlags_Sortable | - ImGuiTableFlags_SortMulti | - ImGuiTableFlags_RowBg | - ImGuiTableFlags_Resizable | - ImGuiTableFlags_ScrollY | - ImGuiTableFlags_Reorderable; + auto& U_chrome = ui(); + bool chrome_visible = U_chrome.chrome_user_set ? U_chrome.chrome_user_visible : show_chrome; - if (!ImGui::BeginTable(id, visible_cols, flags, ImVec2(0, 0))) return; - - // Setup columns with UserID = column index del dataset original. - for (int c = 0; c < col_count; ++c) { - if (!st.col_visible[c]) continue; - ImGui::TableSetupColumn(headers[c], ImGuiTableColumnFlags_None, 0.0f, (ImGuiID)c); - } - ImGui::TableSetupScrollFreeze(0, 1); - - // Custom header row para soportar right-click context menu por columna. - ImGui::TableNextRow(ImGuiTableRowFlags_Headers); - int draw_col = 0; - for (int c = 0; c < col_count; ++c) { - if (!st.col_visible[c]) continue; - ImGui::TableSetColumnIndex(draw_col++); - ImGui::PushID(c); - ImGui::TableHeader(headers[c]); - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - U.header_popup_col = c; - ImGui::OpenPopup("##hdr_menu"); - } - if (ImGui::BeginPopup("##hdr_menu") && U.header_popup_col == c) { - draw_header_menu(st, c, headers, col_count); - ImGui::EndPopup(); - } - ImGui::PopID(); - } - - // Aplicar sort specs de ImGui -> State. - if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) { - if (specs->SpecsDirty && specs->SpecsCount > 0) { - const ImGuiTableColumnSortSpecs& s = specs->Specs[0]; - st.sort_col = (int)s.ColumnUserID; - st.sort_desc = (s.SortDirection == ImGuiSortDirection_Descending); - specs->SpecsDirty = false; - visible_rows = compute_visible_rows(cells, row_count, col_count, st); - } else if (specs->SpecsCount == 0 && st.sort_col >= 0) { - st.sort_col = -1; - visible_rows = compute_visible_rows(cells, row_count, col_count, st); + // Toggle Hide/Show UI siempre visible (botoncito arriba a la derecha). + { + float right = ImGui::GetWindowContentRegionMax().x; + ImGui::SetCursorPosX(right - 90.0f); + if (ImGui::SmallButton(chrome_visible ? "Hide UI##chrome" : "Show UI##chrome")) { + U_chrome.chrome_user_set = true; + U_chrome.chrome_user_visible = !chrome_visible; } } - // Body. - for (int r : visible_rows) { - ImGui::TableNextRow(); - int dc = 0; - for (int c = 0; c < col_count; ++c) { - if (!st.col_visible[c]) continue; - ImGui::TableSetColumnIndex(dc++); - const char* cell = cells[r * col_count + c]; - for (const auto& cr : st.color_rules) { - if (cr.col == c && cell && cr.equals == cell) { - ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, (ImU32)cr.color); - break; + // Main source dropdown — solo si > 1 tabla disponibles. + if (chrome_visible && tables.size() > 1) { + ImGui::SameLine(); + float right = ImGui::GetWindowContentRegionMax().x; + ImGui::SetCursorPosX(right - 90.0f - 280.0f); + ImGui::TextDisabled("Main table:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(180); + const char* cur_main = main_t.name.c_str(); + if (ImGui::BeginCombo("##main_table", cur_main)) { + for (const auto& t : tables) { + bool sel = (t.name == cur_main); + if (ImGui::Selectable(t.name.c_str(), sel)) { + st.main_source = t.name; } } - ImGui::PushID(r * col_count + c); - if (ImGui::Selectable(cell ? cell : "", false, ImGuiSelectableFlags_AllowDoubleClick)) { - U.pending_col = c; - U.pending_value = cell ? cell : ""; - U.open_cell_popup = true; - } - ImGui::PopID(); + ImGui::EndCombo(); } } - ImGui::EndTable(); + st.ensure_stage0(); + + // -------- Pre-pipeline: materialize joins -------- + // Si state.joins no vacio + joinables provistos, ejecutar chain de join_tables. + // El resultado reemplaza headers/cells/declared_types para el resto del render. + static thread_local std::vector joined_headers_store; + static thread_local std::vector joined_types_store; + static thread_local std::vector joined_headers_ptrs; + static thread_local std::vector joined_cells_ptrs; + static thread_local std::vector joined_declared_types; + static thread_local StageOutput joined_so; + + const char* const* headers = headers_in; + const char* const* cells = cells_in; + int row_count = row_count_in; + int orig_cols = col_count; + const ColumnType* declared_types = declared_types_in; + + bool joined = false; + if (!st.joins.empty() && joinables && !joinables->empty()) { + joined_so = StageOutput{}; + // Build initial left from main. + std::vector cur_h(orig_cols); + std::vector cur_t(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + cur_h[c] = headers_in[c]; + cur_t[c] = declared_types_in ? declared_types_in[c] : ColumnType::Auto; + } + const char* const* cur_cells = cells_in; + int cur_rows = row_count_in; + int cur_cols = orig_cols; + + // Chain join por cada joins[i]. + std::vector chain; + chain.reserve(st.joins.size()); + for (const auto& jn : st.joins) { + const TableInput* match = nullptr; + for (const auto& ti : *joinables) { + if (ti.name == jn.source) { match = &ti; break; } + } + if (!match) continue; + StageOutput so = join_tables(cur_cells, cur_rows, cur_cols, + cur_h, cur_t, *match, jn); + chain.push_back(std::move(so)); + const StageOutput& last = chain.back(); + cur_cells = last.cells.data(); + cur_rows = last.rows; + cur_cols = last.cols; + cur_h = last.headers; + cur_t = last.types; + } + + if (!chain.empty()) { + joined = true; + joined_so = std::move(chain.back()); + joined_headers_store = joined_so.headers; + joined_types_store = joined_so.types; + joined_headers_ptrs.clear(); + joined_cells_ptrs.clear(); + for (const auto& s : joined_headers_store) joined_headers_ptrs.push_back(s.c_str()); + for (const auto& s : joined_so.cell_backing) joined_cells_ptrs.push_back(s.c_str()); + joined_declared_types = joined_types_store; + + headers = joined_headers_ptrs.data(); + cells = joined_cells_ptrs.data(); + row_count = joined_so.rows; + orig_cols = joined_so.cols; + declared_types = joined_declared_types.data(); + } + } + + Stage& stage0 = st.stages[0]; + int eff_cols = orig_cols + (int)stage0.derived.size(); + + ensure_init(st, eff_cols); + auto& U = ui(); + + // Build eff_headers / src_for_eff / eff_types para STAGE 0. + std::vector eff_headers(eff_cols); + std::vector src_for_eff(eff_cols); + std::vector eff_types(eff_cols); + for (int c = 0; c < eff_cols; ++c) { + if (c < orig_cols) { + eff_headers[c] = headers[c]; + src_for_eff[c] = c; + ColumnType d = declared_types ? declared_types[c] : ColumnType::Auto; + eff_types[c] = effective_type(d, cells, row_count, orig_cols, c); + } else { + const DerivedColumn& d = stage0.derived[c - orig_cols]; + eff_headers[c] = d.name.c_str(); + src_for_eff[c] = d.source_col; + eff_types[c] = d.type; + } + } + + static thread_local std::vector hn_storage; + static thread_local std::unordered_map name_to_col; + static thread_local std::unordered_map derived_n2i; + hn_storage.clear(); + name_to_col.clear(); + derived_n2i.clear(); + hn_storage.reserve(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + hn_storage.emplace_back(headers[c]); + name_to_col[hn_storage.back()] = c; + } + for (int i = 0; i < (int)stage0.derived.size(); ++i) { + derived_n2i[stage0.derived[i].name] = i; + } + + // Re-fit auto en cambio de display, stage o config. + auto hash_cfg = [](const ViewConfig& c) -> size_t { + std::string s = c.x_col + "|" + c.cat_col + "|" + c.size_col; + for (auto& y : c.y_cols) { s += "|"; s += y; } + s += "|"; s += std::to_string(c.primary_color); + s += "|"; s += std::to_string(c.hist_bins); + s += "|"; s += std::to_string(c.pie_radius); + s += "|"; s += c.show_legend ? "1" : "0"; + s += "|"; s += c.show_markers ? "1" : "0"; + return std::hash{}(s); + }; + size_t cur_cfg_h = hash_cfg(st.viz_config); + if (U.prev_viz_display != st.display || U.prev_viz_stage != st.active_stage || + U.prev_viz_cfg_h != cur_cfg_h) { + st.viz_config.fit_request = true; + U.prev_viz_display = st.display; + U.prev_viz_stage = st.active_stage; + U.prev_viz_cfg_h = cur_cfg_h; + } + + // ----- Breadcrumb + viz selector (chrome) ----- + if (chrome_visible) { + draw_stage_breadcrumb(st); + draw_viz_selector(st); + } + int active = st.active_stage; + bool is_raw = (active == 0); + + // ----- Chips del stage activo ----- + Stage& act = st.stages[active]; + + if (is_raw && chrome_visible) { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2)); + // Joins chip row — solo si hay joinables disponibles. + if (joinables && !joinables->empty()) { + std::vector mh(orig_cols); + for (int c = 0; c < orig_cols; ++c) mh[c] = headers[c]; + draw_joins_chips(st, *joinables, mh); + } + + draw_filter_chips(act, eff_headers.data(), eff_cols); + draw_add_filter_popup(act, eff_headers.data(), eff_cols, eff_types); + draw_edit_filter_popup(act, eff_headers.data(), eff_cols, eff_types); + + // Custom columns chips (solo stage 0) + { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(110, 110, 110, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(140, 140, 140, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 85, 85, 85, 230)); + if (ImGui::SmallButton("+##addcustomcol")) { + U.cf_open = true; + U.cf_editing = false; + U.cf_edit_idx = -1; + U.cf_target_stage = 0; + U.cf_formula.clear(); + U.cf_name.clear(); + U.cf_type = ColumnType::String; + U.cf_error.clear(); + } + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + bool any = false; + for (size_t i = 0; i < stage0.derived.size(); ++i) { + if (stage0.derived[i].formula.empty()) continue; + any = true; + const auto& d = stage0.derived[i]; + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s %s x##custom%zu", + column_type_icon(d.type), d.name.c_str(), i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(140, 140, 140, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(170, 170, 170, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(110, 110, 110, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.cf_open = true; + U.cf_editing = true; + U.cf_edit_idx = (int)i; + U.cf_target_stage = 0; + U.cf_formula = d.formula; + U.cf_name = d.name; + U.cf_type = d.type; + U.cf_error.clear(); + } + if (clicked) { + if (d.lua_id >= 0) lua_engine::release(lua_engine::get(), d.lua_id); + stage0.derived.erase(stage0.derived.begin() + i); + break; + } + ImGui::SameLine(); + } + if (!any) ImGui::TextDisabled("Custom columns: + para anadir."); + else ImGui::NewLine(); + } + + // Sort chips para stage 0 (input headers para popup). + draw_sort_chips(act); + draw_add_sort_popup(act, eff_headers.data(), eff_cols, eff_types); + draw_edit_sort_popup(act, eff_headers.data(), eff_cols); + ImGui::PopStyleVar(); // ItemSpacing + } + + // Para stages 1+, compute input headers/types del stage previo. + // Esto requiere compute_stage chain. Lo haremos abajo. + + // ---------- Compute view: chain compute_stage 0..active ---------- + // Stage 0 expressions: derived cols. Pero compute_stage no sabe de Lua. + // Estrategia: stage 0 lo aplicamos a mano (orig cells + filter + sort) + // y exponemos un eff_cells "virtual" donde derived cols se llenan via Lua + // en el render. Esto preserva el path actual. + // + // Para stages 1+, compute_stage opera sobre cells materializadas. Hay que + // materializar el stage 0 output como cells reales (con derived evaluadas). + + // Simpler: si active == 0, mantener el path actual (orig cells + Lua). + // Si active > 0, materializar stage 0 + chain compute_stage(stage 1..active). + + if (is_raw) { + // ----- Path stage 0: orig cells + filters/sort manuales + Lua per cell. + + // compute_visible_rows opera sobre orig cells. filter.col es eff col, + // hay que traducir a src col (igual que codigo anterior). + State st_tmp = st; + st_tmp.ensure_stage0(); + for (auto& f : st_tmp.stages[0].filters) { + if (f.col >= 0 && f.col < eff_cols) f.col = src_for_eff[f.col]; + } + // Sort: la pasamos por @idx convention. + st_tmp.stages[0].sorts.clear(); + if (!stage0.sorts.empty()) { + // resolve col name -> col idx (de eff_cols) -> src + const SortClause& sc0 = stage0.sorts.front(); + int sc_eff = -1; + for (int c = 0; c < eff_cols; ++c) { + if (std::strcmp(eff_headers[c], sc0.col.c_str()) == 0) { sc_eff = c; break; } + } + if (sc_eff >= 0) { + int sc_src = src_for_eff[sc_eff]; + char tmp[16]; std::snprintf(tmp, sizeof(tmp), "@%d", sc_src); + st_tmp.stages[0].sorts.push_back({tmp, sc0.desc}); + } + } + auto visible_rows = compute_visible_rows(cells, row_count, orig_cols, st_tmp); + + int visible_cols = 0; + for (int k = 0; k < eff_cols; ++k) if (st.col_visible[k]) ++visible_cols; + + // Snapshot del active output (stage 0) para el config popup. + U.active_headers.clear(); + U.active_types.clear(); + for (int k = 0; k < eff_cols; ++k) { + if (!st.col_visible[k]) continue; + U.active_headers.emplace_back(eff_headers[k]); + U.active_types.push_back(eff_types[k]); + } + // Input == orig + derived (stage 0 no tiene upstream que agrupe). + U.input_headers_active = U.active_headers; + U.input_types_active = U.active_types; + + if (chrome_visible) + { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2)); + ImGui::Text("Filas: %d / %d Columnas: %d / %d", + (int)visible_rows.size(), row_count, visible_cols, eff_cols); + ImGui::SameLine(); + if (ImGui::SmallButton(U.stats_mode ? "Hide stats" : "Show stats")) { + U.stats_mode = !U.stats_mode; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Export CSV")) { + std::string out; + bool first = true; + for (int oc = 0; oc < (int)st.col_order.size(); ++oc) { + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + if (!first) out += ','; + out += csv_escape(eff_headers[c]); + first = false; + } + out += '\n'; + for (int r : visible_rows) { + first = true; + for (int oc = 0; oc < (int)st.col_order.size(); ++oc) { + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + int src = src_for_eff[c]; + if (!first) out += ','; + out += csv_escape(cells[r * orig_cols + src]); + first = false; + } + out += '\n'; + } + const char* p = fn::local_path("export_table.csv"); + std::ofstream f(p, std::ios::binary | std::ios::trunc); + if (f) { f << out; U.last_export_path = p; } + } + if (!U.last_export_path.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("-> %s", U.last_export_path.c_str()); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Show TQL")) { + std::vector orig_headers(orig_cols); + std::vector orig_types(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + orig_headers[c] = headers[c]; + orig_types[c] = eff_types[c]; + } + U.tql_show_text = tql::emit(st, orig_headers, orig_types); + U.tql_show_open = true; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Apply TQL")) { + U.tql_apply_open = true; + U.tql_apply_error.clear(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(160); + ImGui::InputText("##tql_file", U.tql_file_path, sizeof(U.tql_file_path)); + ImGui::SameLine(); + if (ImGui::SmallButton("Save .tql")) { + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { oh[c] = headers[c]; ot[c] = eff_types[c]; } + std::string text = tql::emit(st, oh, ot); + const char* path = fn::local_path(U.tql_file_path); + std::ofstream f(path); + if (f) { f << text; U.tql_io_status = std::string("saved: ") + path; } + else { U.tql_io_status = std::string("save FAILED: ") + path; } + } + ImGui::SameLine(); + if (ImGui::SmallButton("Load .tql")) { + const char* path = fn::local_path(U.tql_file_path); + std::ifstream f(path); + if (!f) { U.tql_io_status = std::string("load FAILED: ") + path; } + else { + std::string text((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { oh[c] = headers[c]; ot[c] = eff_types[c]; } + std::string err; + bool ok = tql::apply(text, st, oh, ot, cells, row_count, orig_cols, &err); + if (ok) U.tql_io_status = std::string("loaded: ") + path + (err.empty() ? "" : " (warn: " + err + ")"); + else U.tql_io_status = std::string("load parse error: ") + err; + } + } + if (!U.tql_io_status.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", U.tql_io_status.c_str()); + } + ImGui::PopStyleVar(); + } // chrome_visible + maybe_recompute_stats(cells, row_count, orig_cols, eff_cols, + st_tmp.stages[0].filters, + visible_rows, src_for_eff); + + // Toggle Table <-> View (siempre visible) + draw_table_toggle(st.display, U.last_non_table_main, "main", &st); + + // SO compartido: main viz + extras. Construido on-demand. + StageOutput so_main; + bool so_built = false; + auto build_so = [&]() -> StageOutput& { + if (so_built) return so_main; + so_built = true; + std::vector vcols; + for (int c = 0; c < eff_cols; ++c) if (st.col_visible[c]) vcols.push_back(c); + so_main.cols = (int)vcols.size(); + so_main.rows = (int)visible_rows.size(); + so_main.headers.reserve(so_main.cols); + so_main.types.reserve(so_main.cols); + for (int c : vcols) { + so_main.headers.emplace_back(eff_headers[c]); + so_main.types.push_back(eff_types[c]); + } + so_main.cell_backing.reserve((size_t)so_main.rows * so_main.cols); + for (int r : visible_rows) { + for (int c : vcols) { + if (c < orig_cols) { + const char* p = cells[r * orig_cols + c]; + so_main.cell_backing.emplace_back(p ? p : ""); + } else { + const DerivedColumn& d = stage0.derived[c - orig_cols]; + if (!d.formula.empty() && d.lua_id >= 0) { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string err; + so_main.cell_backing.emplace_back( + lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err)); + } else { + int src = d.source_col; + const char* sp = (src >= 0 && src < orig_cols) ? cells[r * orig_cols + src] : ""; + so_main.cell_backing.emplace_back(sp ? sp : ""); + } + } + } + } + so_main.cells.reserve(so_main.cell_backing.size()); + for (auto& s : so_main.cell_backing) so_main.cells.push_back(s.c_str()); + return so_main; + }; + + if (visible_cols == 0) { + ImGui::TextDisabled("(todas las columnas ocultas)"); + // Modales fuera del table block. + } else if (st.display != ViewMode::Table) { + viz::render(build_so(), st.display, st.viz_config, ImVec2(-1, -1)); + } else { + ImGuiTableFlags flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable(id, visible_cols, flags, ImVec2(0, 0))) { + + for (int dc = 0; dc < (int)st.col_order.size(); ++dc) { + int c = st.col_order[dc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + ImGui::TableSetupColumn(eff_headers[c], ImGuiTableColumnFlags_None, 0.0f, (ImGuiID)c); + } + ImGui::TableSetupScrollFreeze(0, 1); + + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int dc = 0; dc < (int)st.col_order.size(); ++dc) { + int c = st.col_order[dc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + ImGui::TableSetColumnIndex(dc); // visual idx aprox; recomputado por engine + ImGui::PushID(c); + + // Detecta si esta col esta en sorts (primario o secundario) + int sort_pos = -1; + bool sort_desc = false; + for (size_t si = 0; si < act.sorts.size(); ++si) { + if (act.sorts[si].col == eff_headers[c]) { + sort_pos = (int)si; sort_desc = act.sorts[si].desc; break; + } + } + char arrow[16] = ""; + if (sort_pos == 0) std::snprintf(arrow, sizeof(arrow), " %s", sort_desc ? "v" : "^"); + else if (sort_pos > 0) std::snprintf(arrow, sizeof(arrow), " %s%d", sort_desc ? "v" : "^", sort_pos + 1); + char label[200]; + std::snprintf(label, sizeof(label), "%s %s%s", + column_type_icon(eff_types[c]), eff_headers[c], arrow); + + ImGui::PushStyleColor(ImGuiCol_Header, IM_COL32(45, 50, 65, 200)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, IM_COL32(65, 75, 95, 220)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, IM_COL32(80, 95, 130, 240)); + bool clicked = ImGui::Selectable(label, false, ImGuiSelectableFlags_DontClosePopups); + ImGui::PopStyleColor(3); + + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + ImGui::SetDragDropPayload("##colreorder", &c, sizeof(int)); + ImGui::Text("Move %s", eff_headers[c]); + ImGui::EndDragDropSource(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("##colreorder")) { + int src = *(const int*)p->Data; + reorder_column(st, src, c); + } + ImGui::EndDragDropTarget(); + } + if (clicked) { + bool shift = ImGui::GetIO().KeyShift; + apply_header_sort_click(act, eff_headers[c], shift); + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.header_popup_col = c; + ImGui::OpenPopup("##hdr_menu"); + } + if (ImGui::BeginPopup("##hdr_menu") && U.header_popup_col == c) { + draw_header_menu(st, act, c, eff_headers.data(), eff_cols, eff_types, orig_cols, true); + ImGui::EndPopup(); + } + + if (U.stats_mode && c < (int)U.stats_cache.size()) { + const ColStats& s = U.stats_cache[c]; + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(170, 190, 220, 220)); + ImGui::Text("missing: %d", s.empty_count); + ImGui::Text("uniq: %d%s", s.unique_count, s.unique_capped ? "+" : ""); + if (s.numeric) { + ImGui::Text("mean: %.2f", s.mean); + ImGui::Text("p25: %.2f", s.p25); + ImGui::Text("p50: %.2f", s.p50); + ImGui::Text("p75: %.2f", s.p75); + if (!s.hist.empty()) { + char overlay[64]; + std::snprintf(overlay, sizeof(overlay), "[%.2f..%.2f]", s.min, s.max); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(70, 140, 220, 230)); + ImGui::PlotHistogram("##hist", s.hist.data(), (int)s.hist.size(), + 0, overlay, 0.0f, FLT_MAX, ImVec2(-1, 36)); + ImGui::PopStyleColor(); + } + } else if (!s.top_categories.empty()) { + int mx = 0; + for (const auto& kv : s.top_categories) if (kv.second > mx) mx = kv.second; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(70, 140, 220, 220)); + for (const auto& kv : s.top_categories) { + float frac = mx > 0 ? (float)kv.second / (float)mx : 0.f; + char ovl[96]; + std::snprintf(ovl, sizeof(ovl), "%s (%d)", kv.first.c_str(), kv.second); + ImGui::ProgressBar(frac, ImVec2(-1, 12), ovl); + } + ImGui::PopStyleColor(); + } + ImGui::PopStyleColor(); + } + ImGui::PopID(); + } + + int sel_rmin = std::min(U.sel_anchor_row, U.sel_end_row); + int sel_rmax = std::max(U.sel_anchor_row, U.sel_end_row); + int sel_cmin = std::min(U.sel_anchor_col, U.sel_end_col); + int sel_cmax = std::max(U.sel_anchor_col, U.sel_end_col); + + ImGuiListClipper clipper; + clipper.Begin((int)visible_rows.size()); + while (clipper.Step()) { + for (int ri = clipper.DisplayStart; ri < clipper.DisplayEnd; ++ri) { + int r = visible_rows[ri]; + ImGui::TableNextRow(); + int draw_idx = 0; + for (int oc = 0; oc < (int)st.col_order.size(); ++oc) { + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + ImGui::TableSetColumnIndex(draw_idx++); + int src = src_for_eff[c]; + std::string eval_buf; + const char* cell; + if (c >= orig_cols && !stage0.derived[c - orig_cols].formula.empty()) { + const auto& d = stage0.derived[c - orig_cols]; + if (d.lua_id < 0) cell = "?"; + else { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string err; + eval_buf = lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err); + cell = eval_buf.c_str(); + } + } else { + cell = cells[r * orig_cols + src]; + } + + for (const auto& cr : st.color_rules) { + if (cr.col == c && cell && cr.equals == cell) { + ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, (ImU32)cr.color); + break; + } + } + bool in_sel = (U.sel_active && + ri >= sel_rmin && ri <= sel_rmax && + oc >= sel_cmin && oc <= sel_cmax); + ImGui::PushID(r * eff_cols + c); + ImGui::Selectable(cell ? cell : "", in_sel, + ImGuiSelectableFlags_AllowDoubleClick); + // AllowWhenBlockedByActiveItem: durante drag, + // otras celdas tambien reciben hover -> sel se + // pinta mientras arrastras. + bool hovered = ImGui::IsItemHovered( + ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + if (hovered) { + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + U.sel_anchor_row = ri; U.sel_anchor_col = oc; + U.sel_end_row = ri; U.sel_end_col = oc; + U.sel_active = true; + U.sel_dragging = true; + } else if (U.sel_dragging) { + U.sel_end_row = ri; U.sel_end_col = oc; + } + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + U.pending_col = c; + U.pending_value = cell ? cell : ""; + U.open_cell_popup = true; + } + } + ImGui::PopID(); + } + } + } + if (U.sel_dragging && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + U.sel_dragging = false; + } + ImGui::EndTable(); + } + + // Ctrl+C -> TSV. + if (U.sel_active && ImGui::GetIO().KeyCtrl && + ImGui::IsKeyPressed(ImGuiKey_C, false)) + { + int rmin = std::min(U.sel_anchor_row, U.sel_end_row); + int rmax = std::max(U.sel_anchor_row, U.sel_end_row); + int cmin = std::min(U.sel_anchor_col, U.sel_end_col); + int cmax = std::max(U.sel_anchor_col, U.sel_end_col); + std::string out; + bool first = true; + for (int oc = cmin; oc <= cmax; ++oc) { + if (oc < 0 || oc >= (int)st.col_order.size()) continue; + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + if (!first) out += '\t'; + out += eff_headers[c]; + first = false; + } + out += '\n'; + for (int ri = rmin; ri <= rmax; ++ri) { + if (ri < 0 || ri >= (int)visible_rows.size()) continue; + int r = visible_rows[ri]; + first = true; + for (int oc = cmin; oc <= cmax; ++oc) { + if (oc < 0 || oc >= (int)st.col_order.size()) continue; + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + int src = src_for_eff[c]; + const char* v = cells[r * orig_cols + src]; + std::string sv = v ? v : ""; + for (char& ch : sv) if (ch == '\t' || ch == '\n') ch = ' '; + if (!first) out += '\t'; + out += sv; + first = false; + } + out += '\n'; + } + ImGui::SetClipboardText(out.c_str()); + } + } + + // Render extras panels (stage 0 path) + if (!st.extra_panels.empty() && visible_cols > 0) { + int close_idx = -1; + for (int i = 0; i < (int)st.extra_panels.size(); ++i) { + if (draw_extra_panel(st, st.extra_panels[i], i, build_so())) close_idx = i; + } + if (close_idx >= 0) st.extra_panels.erase(st.extra_panels.begin() + close_idx); + } + } else { + // ----- Path stage > 0: materializar stage 0 con cells reales + chain. + // Materializar stage 0: aplicar filters/sort sobre orig + evaluar derived. + State st_tmp = st; + for (auto& f : st_tmp.stages[0].filters) { + if (f.col >= 0 && f.col < eff_cols) f.col = src_for_eff[f.col]; + } + st_tmp.stages[0].sorts.clear(); + if (!stage0.sorts.empty()) { + const SortClause& sc0 = stage0.sorts.front(); + int sc_eff = -1; + for (int c = 0; c < eff_cols; ++c) { + if (std::strcmp(eff_headers[c], sc0.col.c_str()) == 0) { sc_eff = c; break; } + } + if (sc_eff >= 0) { + int sc_src = src_for_eff[sc_eff]; + char tmp[16]; std::snprintf(tmp, sizeof(tmp), "@%d", sc_src); + st_tmp.stages[0].sorts.push_back({tmp, sc0.desc}); + } + } + auto vrows = compute_visible_rows(cells, row_count, orig_cols, st_tmp); + + // Materializar stage0 output: cells (eff_cols) con derived evaluadas. + std::vector mat_backing; + std::vector mat_cells; + mat_backing.reserve((size_t)vrows.size() * eff_cols); + mat_cells.reserve((size_t)vrows.size() * eff_cols); + + for (int r : vrows) { + for (int c = 0; c < eff_cols; ++c) { + const char* p; + std::string buf; + if (c < orig_cols) { + p = cells[r * orig_cols + c]; + mat_backing.emplace_back(p ? p : ""); + } else { + const DerivedColumn& d = stage0.derived[c - orig_cols]; + if (!d.formula.empty()) { + if (d.lua_id < 0) { + mat_backing.emplace_back(""); + } else { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string err; + mat_backing.emplace_back( + lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err)); + } + } else { + // retipo puro + int src = d.source_col; + const char* sp = (src >= 0 && src < orig_cols) ? cells[r * orig_cols + src] : ""; + mat_backing.emplace_back(sp ? sp : ""); + } + } + } + } + // Punteros tras llenar backing (reserve garantiza no realloc). + for (auto& s : mat_backing) mat_cells.push_back(s.c_str()); + + std::vector cur_headers(eff_cols); + std::vector cur_types(eff_cols); + for (int c = 0; c < eff_cols; ++c) { + cur_headers[c] = eff_headers[c]; + cur_types[c] = eff_types[c]; + } + + // Chain compute_stage 1..active. + // Para encadenar, mantenemos vectores por iteracion. cur_cells apunta al + // ultimo output. + const char* const* cur_cells = mat_cells.data(); + int cur_rows = (int)vrows.size(); + int cur_cols_n = eff_cols; + + std::vector outs; + outs.reserve(st.stages.size()); + + // Headers del INPUT del active (= output del active-1) + std::vector input_headers_active = cur_headers; + std::vector input_types_active = cur_types; + + for (int si = 1; si <= active; ++si) { + const Stage& sN = st.stages[si]; + // Antes de computar: si es el active stage, los input_headers son cur_*. + if (si == active) { + input_headers_active = cur_headers; + input_types_active = cur_types; + } + StageOutput so = compute_stage(cur_cells, cur_rows, cur_cols_n, + cur_headers, cur_types, sN); + outs.push_back(std::move(so)); + const StageOutput& last = outs.back(); + cur_cells = last.cells.data(); + cur_rows = last.rows; + cur_cols_n = last.cols; + cur_headers = last.headers; + cur_types = last.types; + } + + // ----- Chips del active stage (uses input_headers_active) ----- + std::vector ih_ptrs(input_headers_active.size()); + for (size_t i = 0; i < input_headers_active.size(); ++i) + ih_ptrs[i] = input_headers_active[i].c_str(); + int in_cols_n = (int)input_headers_active.size(); + + if (chrome_visible) { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2)); + draw_filter_chips(act, ih_ptrs.data(), in_cols_n); + draw_add_filter_popup(act, ih_ptrs.data(), in_cols_n, input_types_active); + draw_edit_filter_popup(act, ih_ptrs.data(), in_cols_n, input_types_active); + + draw_breakout_chips(act, ih_ptrs.data(), in_cols_n); + draw_add_breakout_popup(act, ih_ptrs.data(), in_cols_n, input_types_active); + draw_edit_breakout_popup(act, ih_ptrs.data(), in_cols_n); + + draw_aggregation_chips(act, ih_ptrs.data(), in_cols_n); + draw_add_aggregation_popup(act, ih_ptrs.data(), in_cols_n, input_types_active); + draw_edit_agg_popup(act, ih_ptrs.data(), in_cols_n); + + // ----- Custom column chips (stages 1+, target = active stage) ----- + { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(110, 110, 110, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(140, 140, 140, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 85, 85, 85, 230)); + if (ImGui::SmallButton("+##addcustomcol_stage")) { + U.cf_open = true; + U.cf_editing = false; + U.cf_edit_idx = -1; + U.cf_target_stage = active; + U.cf_formula.clear(); + U.cf_name.clear(); + U.cf_type = ColumnType::String; + U.cf_error.clear(); + } + ImGui::PopStyleColor(3); + ImGui::SameLine(); + bool any = false; + for (size_t i = 0; i < act.derived.size(); ++i) { + if (act.derived[i].formula.empty()) continue; + any = true; + const auto& d = act.derived[i]; + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s %s x##custom_st_%zu", + column_type_icon(d.type), d.name.c_str(), i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(140, 140, 140, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(170, 170, 170, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(110, 110, 110, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.cf_open = true; + U.cf_editing = true; + U.cf_edit_idx = (int)i; + U.cf_target_stage = active; + U.cf_formula = d.formula; + U.cf_name = d.name; + U.cf_type = d.type; + U.cf_error.clear(); + } + if (clicked) { + if (d.lua_id >= 0) lua_engine::release(lua_engine::get(), d.lua_id); + act.derived.erase(act.derived.begin() + i); + break; + } + ImGui::SameLine(); + } + if (!any) ImGui::TextDisabled("Custom columns (stage %d): + para anadir.", active); + else ImGui::NewLine(); + } + + draw_sort_chips(act); + // Sort col options son los headers del OUTPUT del stage activo. + std::vector out_h_ptrs(cur_headers.size()); + for (size_t i = 0; i < cur_headers.size(); ++i) out_h_ptrs[i] = cur_headers[i].c_str(); + draw_add_sort_popup(act, out_h_ptrs.data(), (int)cur_headers.size(), cur_types); + draw_edit_sort_popup(act, out_h_ptrs.data(), (int)cur_headers.size()); + ImGui::PopStyleVar(); + } // chrome_visible + + // ----- Materializar act.derived sobre cur_cells ----- + // Para cada derived col formula del active stage, eval per output row. + std::vector ext_backing; + std::vector ext_cells; + std::vector ext_headers; + std::vector ext_types; + if (!act.derived.empty()) { + int orig_out_cols = cur_cols_n; + std::vector out_hn = cur_headers; + std::unordered_map out_n2c; + for (size_t i = 0; i < out_hn.size(); ++i) out_n2c[out_hn[i]] = (int)i; + int n_derived = (int)act.derived.size(); + int new_cols = orig_out_cols + n_derived; + ext_backing.reserve((size_t)cur_rows * n_derived); + ext_cells.reserve((size_t)cur_rows * new_cols); + for (int r = 0; r < cur_rows; ++r) { + // copia cols originales del output + for (int c = 0; c < orig_out_cols; ++c) { + ext_cells.push_back(cur_cells[r * orig_out_cols + c]); + } + // anade derived eval + for (int k = 0; k < n_derived; ++k) { + const DerivedColumn& d = act.derived[k]; + if (d.formula.empty() || d.lua_id < 0) { + ext_backing.emplace_back(""); + } else { + lua_engine::RowCtx ctx; + ctx.cells = cur_cells; + ctx.orig_cols = orig_out_cols; + ctx.row = r; + ctx.header_names = &out_hn; + ctx.name_to_col = &out_n2c; + ctx.types_orig = cur_types.data(); + ctx.n_types_orig = orig_out_cols; + std::string e; + ext_backing.emplace_back( + lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &e)); + } + // marker placeholder; sera replaced abajo tras backing estable + ext_cells.push_back(nullptr); + } + } + // Construir ext_cells reemplazando placeholders por punteros estables. + size_t bi = 0; + for (int r = 0; r < cur_rows; ++r) { + for (int k = 0; k < n_derived; ++k) { + int idx = r * new_cols + orig_out_cols + k; + ext_cells[idx] = ext_backing[bi++].c_str(); + } + } + ext_headers = cur_headers; + ext_types = cur_types; + for (int k = 0; k < n_derived; ++k) { + ext_headers.push_back(act.derived[k].name); + ext_types.push_back(act.derived[k].type); + } + cur_cells = ext_cells.data(); + cur_cols_n = new_cols; + cur_headers = ext_headers; + cur_types = ext_types; + } + + // Header row + cells render simple (sin clipper porque outputs son + // pequenos tipicamente). + // Snapshot del active output (stage>0) para config popup. + U.active_headers = cur_headers; + U.active_types = cur_types; + // Input del active stage = output del previo. Disponible en + // input_headers_active/input_types_active. + U.input_headers_active = input_headers_active; + U.input_types_active = input_types_active; + + if (chrome_visible) { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2)); + ImGui::Text("Filas: %d Columnas: %d", cur_rows, cur_cols_n); + ImGui::SameLine(); + if (ImGui::SmallButton(U.stats_mode ? "Hide stats" : "Show stats")) { + U.stats_mode = !U.stats_mode; + } + // Recompute stats sobre cur_cells del stage activo. + if (U.stats_mode && cur_cols_n > 0) { + U.stats_cache.resize(cur_cols_n); + U.last_cells = cur_cells; + for (int c = 0; c < cur_cols_n; ++c) { + U.stats_cache[c] = compute_column_stats(cur_cells, cur_rows, cur_cols_n, c); + } + } + ImGui::SameLine(); + if (ImGui::SmallButton("Show TQL")) { + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + oh[c] = headers[c]; + ot[c] = eff_types[c]; + } + U.tql_show_text = tql::emit(st, oh, ot); + U.tql_show_open = true; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Apply TQL")) { + U.tql_apply_open = true; + U.tql_apply_error.clear(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(160); + ImGui::InputText("##tql_file2", U.tql_file_path, sizeof(U.tql_file_path)); + ImGui::SameLine(); + if (ImGui::SmallButton("Save .tql##s2")) { + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { oh[c] = headers[c]; ot[c] = eff_types[c]; } + std::string text = tql::emit(st, oh, ot); + const char* path = fn::local_path(U.tql_file_path); + std::ofstream f(path); + if (f) { f << text; U.tql_io_status = std::string("saved: ") + path; } + else { U.tql_io_status = std::string("save FAILED: ") + path; } + } + ImGui::SameLine(); + if (ImGui::SmallButton("Load .tql##l2")) { + const char* path = fn::local_path(U.tql_file_path); + std::ifstream f(path); + if (!f) { U.tql_io_status = std::string("load FAILED: ") + path; } + else { + std::string text((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { oh[c] = headers[c]; ot[c] = eff_types[c]; } + std::string err; + bool ok = tql::apply(text, st, oh, ot, cells, row_count, orig_cols, &err); + if (ok) U.tql_io_status = std::string("loaded: ") + path + (err.empty() ? "" : " (warn: " + err + ")"); + else U.tql_io_status = std::string("load parse error: ") + err; + } + } + if (!U.tql_io_status.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", U.tql_io_status.c_str()); + } + ImGui::PopStyleVar(); + } // chrome_visible + + // Toggle Table <-> View (siempre visible) + draw_table_toggle(st.display, U.last_non_table_main, "main2", &st); + + if (st.display != ViewMode::Table && cur_cols_n > 0) { + // outs.back() es el StageOutput del active. Si active no tiene outs + // (cur_rows poblado pero outs vacio cuando active>0 y chain corta), + // construir uno on-the-fly desde cur_cells. + StageOutput so_local; + const StageOutput* so_ptr = nullptr; + if (!outs.empty()) { + so_ptr = &outs.back(); + } else { + so_local.cols = cur_cols_n; + so_local.rows = cur_rows; + so_local.headers = cur_headers; + so_local.types = cur_types; + so_local.cells.reserve((size_t)cur_rows * cur_cols_n); + for (int i = 0; i < cur_rows * cur_cols_n; ++i) + so_local.cells.push_back(cur_cells[i]); + so_ptr = &so_local; + } + viz::render(*so_ptr, st.display, st.viz_config, ImVec2(-1, -1)); + goto stage_n_table_end; + } + + { + ImGuiTableFlags flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY; + if (cur_cols_n > 0 && ImGui::BeginTable(id, cur_cols_n, flags, ImVec2(0, 0))) { + for (int c = 0; c < cur_cols_n; ++c) { + ImGui::TableSetupColumn(cur_headers[c].c_str(), + ImGuiTableColumnFlags_None, 0.0f, (ImGuiID)c); + } + ImGui::TableSetupScrollFreeze(0, 1); + + // Custom header row: nombre + icon + stats inline si stats_mode. + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int c = 0; c < cur_cols_n; ++c) { + ImGui::TableSetColumnIndex(c); + // Sort indicator + int sort_pos = -1; + bool sort_desc = false; + for (size_t si = 0; si < act.sorts.size(); ++si) { + if (act.sorts[si].col == cur_headers[c]) { + sort_pos = (int)si; sort_desc = act.sorts[si].desc; break; + } + } + char arrow[16] = ""; + if (sort_pos == 0) std::snprintf(arrow, sizeof(arrow), " %s", sort_desc ? "v" : "^"); + else if (sort_pos > 0) std::snprintf(arrow, sizeof(arrow), " %s%d", sort_desc ? "v" : "^", sort_pos + 1); + + ImGui::PushStyleColor(ImGuiCol_Header, IM_COL32(45, 50, 65, 200)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, IM_COL32(65, 75, 95, 220)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, IM_COL32(80, 95, 130, 240)); + char lbl[200]; + std::snprintf(lbl, sizeof(lbl), "%s %s%s", + column_type_icon(cur_types[c]), + cur_headers[c].c_str(), arrow); + bool h_clicked = ImGui::Selectable(lbl, false, ImGuiSelectableFlags_DontClosePopups); + ImGui::PopStyleColor(3); + if (h_clicked) { + bool shift = ImGui::GetIO().KeyShift; + apply_header_sort_click(act, cur_headers[c], shift); + } + + if (U.stats_mode && c < (int)U.stats_cache.size()) { + const ColStats& s = U.stats_cache[c]; + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(170, 190, 220, 220)); + ImGui::Text("missing: %d", s.empty_count); + ImGui::Text("uniq: %d%s", s.unique_count, s.unique_capped ? "+" : ""); + if (s.numeric) { + ImGui::Text("mean: %.2f", s.mean); + ImGui::Text("p25: %.2f", s.p25); + ImGui::Text("p50: %.2f", s.p50); + ImGui::Text("p75: %.2f", s.p75); + if (!s.hist.empty()) { + char overlay[64]; + std::snprintf(overlay, sizeof(overlay), "[%.2f..%.2f]", s.min, s.max); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(70, 140, 220, 230)); + ImGui::PlotHistogram("##hist", s.hist.data(), (int)s.hist.size(), + 0, overlay, 0.0f, FLT_MAX, ImVec2(-1, 36)); + ImGui::PopStyleColor(); + } + } else if (!s.top_categories.empty()) { + int mx = 0; + for (const auto& kv : s.top_categories) if (kv.second > mx) mx = kv.second; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(70, 140, 220, 220)); + for (const auto& kv : s.top_categories) { + float frac = mx > 0 ? (float)kv.second / (float)mx : 0.f; + char ovl[96]; + std::snprintf(ovl, sizeof(ovl), "%s (%d)", kv.first.c_str(), kv.second); + ImGui::ProgressBar(frac, ImVec2(-1, 12), ovl); + } + ImGui::PopStyleColor(); + } + ImGui::PopStyleColor(); + } + } + + int n_brk = (int)st.stages[active].breakouts.size(); + + for (int r = 0; r < cur_rows; ++r) { + ImGui::TableNextRow(); + for (int c = 0; c < cur_cols_n; ++c) { + ImGui::TableSetColumnIndex(c); + const char* cell = cur_cells[r * cur_cols_n + c]; + ImGui::PushID(r * cur_cols_n + c); + ImGui::Selectable(cell ? cell : ""); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + // Drill-down solo si c es col de breakout (c < n_brk). + if (c < n_brk) { + U.pending_col = c; + U.pending_value = cell ? cell : ""; + ImGui::OpenPopup("##drill_popup"); + } + } + if (ImGui::BeginPopup("##drill_popup")) { + if (c < n_brk) { + char lbl[256]; + std::snprintf(lbl, sizeof(lbl), "Drill into: %s = %s", + cur_headers[c].c_str(), cell ? cell : ""); + if (ImGui::MenuItem(lbl)) { + drill_into(st, active, cur_headers[c], + cell ? std::string(cell) : "", + input_headers_active); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + ImGui::PopID(); + } + } + ImGui::EndTable(); + } + } + stage_n_table_end:; + + // Render extras (stage>0 path) + if (!st.extra_panels.empty() && cur_cols_n > 0) { + StageOutput so_local; + const StageOutput* so_ptr = nullptr; + if (!outs.empty()) { + so_ptr = &outs.back(); + } else { + so_local.cols = cur_cols_n; + so_local.rows = cur_rows; + so_local.headers = cur_headers; + so_local.types = cur_types; + so_local.cells.reserve((size_t)cur_rows * cur_cols_n); + for (int i = 0; i < cur_rows * cur_cols_n; ++i) + so_local.cells.push_back(cur_cells[i]); + so_ptr = &so_local; + } + int close_idx = -1; + for (int i = 0; i < (int)st.extra_panels.size(); ++i) { + if (draw_extra_panel(st, st.extra_panels[i], i, *so_ptr)) close_idx = i; + } + if (close_idx >= 0) st.extra_panels.erase(st.extra_panels.begin() + close_idx); + } + } + + // ---------- Modales (comunes a ambos paths) ---------- + if (U.cf_open) ImGui::OpenPopup("Custom column"); + if (ImGui::BeginPopupModal("Custom column", &U.cf_open, + ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("Nombre:"); + char name_buf[128] = {0}; + std::snprintf(name_buf, sizeof(name_buf), "%s", U.cf_name.c_str()); + ImGui::SetNextItemWidth(520); + if (ImGui::InputText("##cfname", name_buf, sizeof(name_buf))) U.cf_name = name_buf; + + ImGui::Spacing(); + ImGui::Text("Formula (Lua). Acceso celdas via row. o row[idx]."); + ImGui::TextDisabled("Ejemplo: return row.size_kb * 1024"); + + static char formula_buf[4096] = {0}; + if (U.cf_force_cursor || std::strcmp(formula_buf, U.cf_formula.c_str()) != 0) { + std::snprintf(formula_buf, sizeof(formula_buf), "%s", U.cf_formula.c_str()); + } + ImGuiInputTextFlags flags = + ImGuiInputTextFlags_CallbackEdit | ImGuiInputTextFlags_CallbackAlways; + if (ImGui::InputTextMultiline("##cfformula", formula_buf, sizeof(formula_buf), + ImVec2(520, 200), flags, autocomplete_cb, &U)) { + U.cf_formula = formula_buf; + } + if (U.cf_ac_open) { + ImVec2 box_min = ImGui::GetItemRectMin(); + ImVec2 box_max = ImGui::GetItemRectMax(); + ImGui::SetNextWindowPos(ImVec2(box_min.x + 20, box_max.y + 4)); + ImGui::SetNextWindowSize(ImVec2(280, 0)); + ImGuiWindowFlags wf = + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_AlwaysAutoResize; + if (ImGui::Begin("##colpicker", nullptr, wf)) { + ImGui::TextDisabled("Pick column:"); + ImGui::Separator(); + auto ci_contains = [](const std::string& hay, const std::string& nd) { + if (nd.empty()) return true; + std::string a = hay, b = nd; + for (char& c : a) if (c >= 'A' && c <= 'Z') c += 32; + for (char& c : b) if (c >= 'A' && c <= 'Z') c += 32; + return a.find(b) != std::string::npos; + }; + int shown = 0; + for (int c = 0; c < eff_cols && shown < 12; ++c) { + std::string nm = eff_headers[c]; + if (!ci_contains(nm, U.cf_ac_filter)) continue; + char lbl[200]; + std::snprintf(lbl, sizeof(lbl), "%s %s", + column_type_icon(eff_types[c]), nm.c_str()); + if (ImGui::Selectable(lbl)) { + int new_cursor = 0; + std::string updated = insert_column_ref( + U.cf_formula, U.cf_ac_start, U.cf_ac_cursor, nm, new_cursor); + U.cf_formula = updated; + U.cf_target_cursor= new_cursor; + U.cf_force_cursor = true; + U.cf_ac_open = false; + } + ++shown; + } + if (shown == 0) ImGui::TextDisabled("(sin matches)"); + } + ImGui::End(); + } + + if (!U.cf_error.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(230, 100, 100, 255)); + ImGui::TextWrapped("Error: %s", U.cf_error.c_str()); + ImGui::PopStyleColor(); + } + + if (ImGui::Button("Compile & save")) { + std::string err; + int lid = lua_engine::compile(lua_engine::get(), U.cf_formula, &err); + if (lid < 0) { + U.cf_error = err; + } else { + // Build sample context segun cf_target_stage. + // target == 0: usa orig cells + stage 0 derived. + // target > 0: recomputa chain hasta el target (excluyendo + // derived del target) y sample sobre ese output. + int ts = U.cf_target_stage; + if (ts < 0 || ts >= (int)st.stages.size()) ts = 0; + int sample = 0; + std::vector samples_str; + + if (ts == 0) { + sample = std::min(64, row_count); + for (int r = 0; r < sample; ++r) { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string e; + samples_str.emplace_back( + lua_engine::eval(lua_engine::get(), lid, ctx, &e)); + } + } else { + // Recompute chain hasta stage ts output (sin aplicar derived + // del propio ts). + State st_sample = st; + // Limpia derived del target stage para que el sample no + // se referencie a si mismo. + if (ts < (int)st_sample.stages.size()) + st_sample.stages[ts].derived.clear(); + // Reusa la logica de materializacion: simple recompute manual. + // Aplica stage 0 (orig + derived) materializado. + State stmp = st; + Stage& s0 = stmp.stages[0]; + for (auto& f : s0.filters) { + if (f.col >= 0 && f.col < eff_cols) f.col = src_for_eff[f.col]; + } + s0.sorts.clear(); + auto v0 = compute_visible_rows(cells, row_count, orig_cols, stmp); + + std::vector mb; + std::vector mc; + mb.reserve((size_t)v0.size() * eff_cols); + mc.reserve((size_t)v0.size() * eff_cols); + for (int r : v0) { + for (int c = 0; c < eff_cols; ++c) { + if (c < orig_cols) { + const char* p = cells[r * orig_cols + c]; + mb.emplace_back(p ? p : ""); + } else { + const DerivedColumn& d = stage0.derived[c - orig_cols]; + if (!d.formula.empty() && d.lua_id >= 0) { + lua_engine::RowCtx ctx; + ctx.cells = cells; ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string e; + mb.emplace_back(lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &e)); + } else if (d.source_col >= 0) { + const char* p = cells[r * orig_cols + d.source_col]; + mb.emplace_back(p ? p : ""); + } else mb.emplace_back(""); + } + } + } + for (auto& s : mb) mc.push_back(s.c_str()); + + std::vector ch(eff_cols); + std::vector ct(eff_cols); + for (int c = 0; c < eff_cols; ++c) { ch[c] = eff_headers[c]; ct[c] = eff_types[c]; } + + const char* const* cc = mc.data(); + int cr = (int)v0.size(); + int cn = eff_cols; + std::vector tmps; + for (int si = 1; si <= ts; ++si) { + Stage stage_sn = st.stages[si]; + // En el target stage NO apliques sus propias derived. + if (si == ts) stage_sn.derived.clear(); + tmps.push_back(compute_stage(cc, cr, cn, ch, ct, stage_sn)); + const StageOutput& l = tmps.back(); + cc = l.cells.data(); cr = l.rows; cn = l.cols; + ch = l.headers; ct = l.types; + } + // Build name_to_col map for the target stage output. + std::vector hn_t = ch; + std::unordered_map n2c_t; + for (size_t i = 0; i < hn_t.size(); ++i) n2c_t[hn_t[i]] = (int)i; + sample = std::min(64, cr); + for (int r = 0; r < sample; ++r) { + lua_engine::RowCtx ctx; + ctx.cells = cc; + ctx.orig_cols = cn; + ctx.row = r; + ctx.header_names = &hn_t; + ctx.name_to_col = &n2c_t; + ctx.types_orig = ct.data(); + ctx.n_types_orig = cn; + std::string e; + samples_str.emplace_back( + lua_engine::eval(lua_engine::get(), lid, ctx, &e)); + } + } + + std::vector samples_ptr; + samples_ptr.reserve(samples_str.size()); + for (auto& s : samples_str) samples_ptr.push_back(s.c_str()); + ColumnType auto_t = auto_detect_type(samples_ptr.data(), + (int)samples_ptr.size(), 1, 0); + + // Save to target stage. + if (ts < 0 || ts >= (int)st.stages.size()) ts = 0; + auto& target_derived = st.stages[ts].derived; + if (U.cf_editing && U.cf_edit_idx >= 0 && + U.cf_edit_idx < (int)target_derived.size()) + { + auto& d = target_derived[U.cf_edit_idx]; + if (d.lua_id >= 0) lua_engine::release(lua_engine::get(), d.lua_id); + d.formula = U.cf_formula; + d.name = U.cf_name.empty() ? "custom" : U.cf_name; + d.type = auto_t; + d.lua_id = lid; + d.compile_error.clear(); + } else { + DerivedColumn d; + d.source_col = -1; + d.type = auto_t; + d.name = U.cf_name.empty() ? "custom" : U.cf_name; + d.formula = U.cf_formula; + d.lua_id = lid; + target_derived.push_back(d); + } + U.cf_open = false; + U.cf_error.clear(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + U.cf_open = false; + U.cf_error.clear(); + } + ImGui::EndPopup(); + } + + if (U.tql_show_open) ImGui::OpenPopup("Show TQL"); + if (ImGui::BeginPopupModal("Show TQL", &U.tql_show_open, + ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("TQL serializado del estado actual (read-only):"); + ImGui::InputTextMultiline("##tqlshow", U.tql_show_text.data(), + U.tql_show_text.size() + 1, + ImVec2(560, 280), + ImGuiInputTextFlags_ReadOnly); + if (ImGui::Button("Copy to clipboard")) { + ImGui::SetClipboardText(U.tql_show_text.c_str()); + } + ImGui::SameLine(); + if (ImGui::Button("Close")) U.tql_show_open = false; + ImGui::EndPopup(); + } + + if (U.tql_apply_open) ImGui::OpenPopup("Apply TQL"); + if (ImGui::BeginPopupModal("Apply TQL", &U.tql_apply_open, + ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("Pega un chunk TQL (Lua). Ver docs/TQL.md para sintaxis."); + static char tql_buf[8192] = {0}; + if (std::strcmp(tql_buf, U.tql_apply_text.c_str()) != 0) { + std::snprintf(tql_buf, sizeof(tql_buf), "%s", U.tql_apply_text.c_str()); + } + if (ImGui::InputTextMultiline("##tqlapply", tql_buf, sizeof(tql_buf), + ImVec2(560, 280))) { + U.tql_apply_text = tql_buf; + } + if (!U.tql_apply_error.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(230, 100, 100, 255)); + ImGui::TextWrapped("Error: %s", U.tql_apply_error.c_str()); + ImGui::PopStyleColor(); + } + if (ImGui::Button("Apply")) { + std::vector orig_headers(orig_cols); + std::vector orig_types(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + orig_headers[c] = headers[c]; + orig_types[c] = eff_types[c]; + } + std::string err; + bool ok = tql::apply(U.tql_apply_text, st, orig_headers, orig_types, + cells, row_count, orig_cols, &err); + if (ok) { + U.tql_apply_open = false; + U.tql_apply_error.clear(); + } else { + U.tql_apply_error = err; + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + U.tql_apply_open = false; + U.tql_apply_error.clear(); + } + ImGui::EndPopup(); + } if (U.open_cell_popup) { ImGui::OpenPopup("##cell_op"); U.open_cell_popup = false; } if (ImGui::BeginPopup("##cell_op")) { - const char* hdr = (U.pending_col >= 0 && U.pending_col < col_count) - ? headers[U.pending_col] : "?"; - ImGui::TextDisabled("%s ?? \"%s\"", hdr, U.pending_value.c_str()); + ColumnType t = (U.pending_col >= 0 && U.pending_col < eff_cols) + ? eff_types[U.pending_col] : ColumnType::String; + const char* hdr = (U.pending_col >= 0 && U.pending_col < eff_cols) + ? eff_headers[U.pending_col] : "?"; + ImGui::TextDisabled("%s %s ?? \"%s\"", + column_type_icon(t), hdr, U.pending_value.c_str()); ImGui::Separator(); - Op picked; - if (draw_op_menu_items(picked)) { - st.filters.push_back({U.pending_col, picked, U.pending_value}); - ImGui::CloseCurrentPopup(); + auto ops = ops_for_type(t); + for (Op o : ops) { + if (ImGui::MenuItem(op_label(o))) { + st.stages[0].filters.push_back({U.pending_col, o, U.pending_value}); + ImGui::CloseCurrentPopup(); + } } ImGui::EndPopup(); } diff --git a/playground/tables/data_table.h b/playground/tables/data_table.h index 77a64ef..f553b1c 100644 --- a/playground/tables/data_table.h +++ b/playground/tables/data_table.h @@ -5,12 +5,14 @@ namespace data_table { // Render barra-de-chips + tabla. Mutates `st` en respuesta a interaccion. -// Caller mantiene el State entre frames. +// `declared_types` opcional: array paralelo a headers con ColumnType por col. +// Si nullptr o ColumnType::Auto -> resuelve via auto_detect_type. +// API unificada: `tables` lista todas las tablas disponibles. La que actua como +// main la elige State.main_source (vacio -> tables[0]). El resto se exponen +// como joinables en la UI cuando size > 1. void render(const char* id, - const char* const* headers, - int col_count, - const char* const* cells, - int row_count, - State& st); + const std::vector& tables, + State& st, + bool show_chrome = true); } // namespace data_table diff --git a/playground/tables/data_table_logic.cpp b/playground/tables/data_table_logic.cpp index ad7bd14..51065e0 100644 --- a/playground/tables/data_table_logic.cpp +++ b/playground/tables/data_table_logic.cpp @@ -1,23 +1,146 @@ #include "data_table_logic.h" #include +#include +#include #include #include +#include +#include +#include namespace data_table { const char* op_label(Op o) { switch (o) { - case Op::Eq: return "="; - case Op::Neq: return "!="; - case Op::Gt: return ">"; - case Op::Gte: return ">="; - case Op::Lt: return "<"; - case Op::Lte: return "<="; + case Op::Eq: return "="; + case Op::Neq: return "!="; + case Op::Gt: return ">"; + case Op::Gte: return ">="; + case Op::Lt: return "<"; + case Op::Lte: return "<="; + case Op::Contains: return "contains"; + case Op::NotContains: return "!contains"; + case Op::StartsWith: return "starts"; + case Op::EndsWith: return "ends"; } return "?"; } +bool op_is_string_only(Op o) { + return o == Op::Contains || o == Op::NotContains || + o == Op::StartsWith || o == Op::EndsWith; +} + +const char* column_type_name(ColumnType t) { + switch (t) { + case ColumnType::Auto: return "auto"; + case ColumnType::String: return "string"; + case ColumnType::Int: return "int"; + case ColumnType::Float: return "float"; + case ColumnType::Bool: return "bool"; + case ColumnType::Date: return "date"; + case ColumnType::Json: return "json"; + } + return "?"; +} + +// Icons Tabler (UTF-8). Mantenidos como strings para no forzar include de icons_tabler.h aqui. +const char* column_type_icon(ColumnType t) { + switch (t) { + case ColumnType::Auto: return "\xef\xa4\x9d"; // TI_HELP_CIRCLE + case ColumnType::String: return "\xef\x95\xa7"; // TI_ABC + case ColumnType::Int: return "\xef\x95\x94"; // TI_123 + case ColumnType::Float: return "\xef\xa8\xa6"; // TI_DECIMAL + case ColumnType::Bool: return "\xee\xae\xa6"; // TI_CHECKBOX + case ColumnType::Date: return "\xee\xa9\x93"; // TI_CALENDAR + case ColumnType::Json: return "\xee\xaf\x8c"; // TI_BRACES + } + return "?"; +} + +std::vector ops_for_type(ColumnType t) { + switch (t) { + case ColumnType::Int: + case ColumnType::Float: + case ColumnType::Date: + return {Op::Eq, Op::Neq, Op::Gt, Op::Gte, Op::Lt, Op::Lte}; + case ColumnType::Bool: + return {Op::Eq, Op::Neq}; + case ColumnType::Json: + return {Op::Eq, Op::Neq, Op::Contains, Op::NotContains}; + case ColumnType::String: + return {Op::Eq, Op::Neq, Op::Contains, Op::NotContains, Op::StartsWith, Op::EndsWith}; + case ColumnType::Auto: + default: + return {Op::Eq, Op::Neq, Op::Contains, Op::NotContains}; + } +} + +namespace { + +bool is_bool_text(const char* s) { + return std::strcmp(s, "true") == 0 || std::strcmp(s, "false") == 0; +} + +bool is_date_iso(const char* s) { + // YYYY-MM-DD minimo + if (std::strlen(s) < 10) return false; + auto d = [](char c){ return c >= '0' && c <= '9'; }; + return d(s[0]) && d(s[1]) && d(s[2]) && d(s[3]) && s[4] == '-' && + d(s[5]) && d(s[6]) && s[7] == '-' && d(s[8]) && d(s[9]); +} + +bool is_json_text(const char* s) { + while (*s == ' ' || *s == '\t') ++s; + return *s == '{' || *s == '['; +} + +bool is_integer_text(const char* s) { + if (!*s) return false; + if (*s == '-' || *s == '+') ++s; + if (!*s) return false; + for (; *s; ++s) if (*s < '0' || *s > '9') return false; + return true; +} + +} // anon + +ColumnType auto_detect_type(const char* const* cells, int rows, int cols, + int col, int sample_n) +{ + if (col < 0 || col >= cols) return ColumnType::String; + int n_total = 0, n_int = 0, n_float = 0, n_bool = 0, n_date = 0, n_json = 0; + for (int r = 0; r < rows && n_total < sample_n; ++r) { + const char* c = cells[r * cols + col]; + if (!c || !*c) continue; + n_total++; + if (is_bool_text(c)) { n_bool++; continue; } + if (is_date_iso(c)) { n_date++; continue; } + if (is_json_text(c)) { n_json++; continue; } + double v; + if (parse_number(c, v)) { + if (is_integer_text(c)) n_int++; + else n_float++; + continue; + } + // string: no se cuenta a ningun tipo -> garantiza fallthrough a String + } + if (n_total == 0) return ColumnType::String; + if (n_bool == n_total) return ColumnType::Bool; + if (n_date == n_total) return ColumnType::Date; + if (n_json == n_total) return ColumnType::Json; + if (n_int + n_float == n_total) return (n_float > 0) ? ColumnType::Float : ColumnType::Int; + return ColumnType::String; +} + +ColumnType effective_type(ColumnType declared, const char* const* cells, + int rows, int cols, int col) +{ + if (declared != ColumnType::Auto) return declared; + return auto_detect_type(cells, rows, cols, col); +} + bool parse_number(const char* s, double& out) { if (!s || !*s) return false; char* end = nullptr; @@ -32,6 +155,20 @@ bool parse_number(const char* s, double& out) { bool compare(const char* a, const char* b, Op op) { if (!a) a = ""; if (!b) b = ""; + // Ops solo de string (siempre lexical, no intentan numeric). + switch (op) { + case Op::Contains: return std::strstr(a, b) != nullptr; + case Op::NotContains: return std::strstr(a, b) == nullptr; + case Op::StartsWith: { + size_t lb = std::strlen(b); + return std::strncmp(a, b, lb) == 0; + } + case Op::EndsWith: { + size_t la = std::strlen(a), lb = std::strlen(b); + return lb <= la && std::strcmp(a + la - lb, b) == 0; + } + default: break; + } double na, nb; bool numeric = parse_number(a, na) && parse_number(b, nb); if (numeric) { @@ -42,6 +179,7 @@ bool compare(const char* a, const char* b, Op op) { case Op::Gte: return na >= nb; case Op::Lt: return na < nb; case Op::Lte: return na <= nb; + default: break; } } int c = std::strcmp(a, b); @@ -52,42 +190,916 @@ bool compare(const char* a, const char* b, Op op) { case Op::Gte: return c >= 0; case Op::Lt: return c < 0; case Op::Lte: return c <= 0; + default: break; } return false; } +// Helpers de State para acceso a stages. +void State::ensure_stage0() { + if (stages.empty()) stages.push_back(Stage{}); + if (active_stage < 0) active_stage = 0; + if (active_stage >= (int)stages.size()) active_stage = (int)stages.size() - 1; +} +Stage& State::raw() { ensure_stage0(); return stages[0]; } +const Stage& State::raw() const { + static thread_local Stage empty; + if (stages.empty()) return empty; + return stages[0]; +} +Stage& State::active() { + ensure_stage0(); + return stages[active_stage]; +} +const Stage& State::active_const() const { + static thread_local Stage empty; + if (stages.empty()) return empty; + int a = active_stage; + if (a < 0 || a >= (int)stages.size()) a = 0; + return stages[a]; +} + +// Compatibilidad: aplica filters + primer sort del stage 0 (Raw). Si el state +// no tiene stages, devuelve todas las filas sin filtrar. Util para tests y +// para el render path actual (que solo opera sobre Raw cuando no hay grouping). std::vector compute_visible_rows(const char* const* cells, int rows, int cols, const State& st) { std::vector out; out.reserve(rows); + const Stage& s = st.raw(); for (int r = 0; r < rows; ++r) { bool keep = true; - for (const auto& f : st.filters) { + for (const auto& f : s.filters) { if (f.col < 0 || f.col >= cols) continue; const char* cell = cells[r * cols + f.col]; if (!compare(cell, f.value.c_str(), f.op)) { keep = false; break; } } if (keep) out.push_back(r); } - if (st.sort_col >= 0 && st.sort_col < cols) { - int sc = st.sort_col; - bool desc = st.sort_desc; - std::sort(out.begin(), out.end(), [&](int a, int b) { - const char* ca = cells[a * cols + sc]; - const char* cb = cells[b * cols + sc]; - if (!ca) ca = ""; - if (!cb) cb = ""; - double na, nb; - bool num = parse_number(ca, na) && parse_number(cb, nb); - int cmp; - if (num) cmp = (na < nb) ? -1 : (na > nb ? 1 : 0); - else cmp = std::strcmp(ca, cb); - return desc ? (cmp > 0) : (cmp < 0); - }); + if (!s.sorts.empty()) { + // El stage 0 stores sorts as {col_name, desc}. Para compat, si el + // nombre es vacio o "@idx", interpretamos como indice numerico. + const SortClause& sc0 = s.sorts.front(); + int sc = -1; + // Permitir nombre numerico estilo "@idx" o lookup posicional via + // primer caracter '@'. Sino, busqueda por header no posible aqui + // (no tenemos headers) — devuelve sin sort. Para compat de tests + // usamos nombre "@N" donde N es indice 0-based. + if (!sc0.col.empty() && sc0.col[0] == '@') { + sc = std::atoi(sc0.col.c_str() + 1); + } + bool desc = sc0.desc; + if (sc >= 0 && sc < cols) { + std::sort(out.begin(), out.end(), [&](int a, int b) { + const char* ca = cells[a * cols + sc]; + const char* cb = cells[b * cols + sc]; + if (!ca) ca = ""; + if (!cb) cb = ""; + double na, nb; + bool num = parse_number(ca, na) && parse_number(cb, nb); + int cmp; + if (num) cmp = (na < nb) ? -1 : (na > nb ? 1 : 0); + else cmp = std::strcmp(ca, cb); + return desc ? (cmp > 0) : (cmp < 0); + }); + } } return out; } +ColStats compute_column_stats(const char* const* cells, int rows, int cols, + int col, int unique_cap, + const int* indices, int n_indices) +{ + ColStats s; + if (col < 0 || col >= cols) return s; + bool use_idx = (indices != nullptr && n_indices > 0); + int n = use_idx ? n_indices : rows; + s.total = n; + std::unordered_map counts; + if (unique_cap > 0) counts.reserve(std::min(unique_cap, n)); + bool all_numeric = true; + std::vector nums; + nums.reserve(n); + for (int i = 0; i < n; ++i) { + int r = use_idx ? indices[i] : i; + if (r < 0 || r >= rows) continue; + const char* c = cells[r * cols + col]; + if (!c || !*c) { s.empty_count++; continue; } + double v; + if (parse_number(c, v)) { + if (s.numeric_count == 0) { s.min = v; s.max = v; } + else { + if (v < s.min) s.min = v; + if (v > s.max) s.max = v; + } + s.sum += v; + s.numeric_count++; + nums.push_back(v); + } else { + all_numeric = false; + } + if (unique_cap == 0 || (int)counts.size() < unique_cap) { + counts[c]++; + } else { + auto it = counts.find(c); + if (it != counts.end()) it->second++; + else s.unique_capped = true; + } + } + s.unique_count = (int)counts.size(); + s.numeric = all_numeric && s.numeric_count > 0; + if (s.numeric_count > 0) s.mean = s.sum / s.numeric_count; + // Top 8 categorias por count desc. + if (!counts.empty()) { + std::vector> v(counts.begin(), counts.end()); + int topN = std::min(8, (int)v.size()); + std::partial_sort(v.begin(), v.begin() + topN, v.end(), + [](const auto& a, const auto& b){ return a.second > b.second; }); + v.resize(topN); + s.top_categories = std::move(v); + } + if (s.numeric && !nums.empty()) { + std::sort(nums.begin(), nums.end()); + auto pct = [&](double p) { + double idx = p * (nums.size() - 1); + size_t lo = (size_t)idx; + size_t hi = std::min(lo + 1, nums.size() - 1); + double t = idx - lo; + return nums[lo] * (1.0 - t) + nums[hi] * t; + }; + s.p25 = pct(0.25); + s.p50 = pct(0.50); + s.p75 = pct(0.75); + + s.hist.assign(HIST_BINS, 0.0f); + double range = s.max - s.min; + if (range <= 0) { + s.hist[HIST_BINS / 2] = (float)nums.size(); + } else { + for (double v : nums) { + int b = (int)((v - s.min) / range * HIST_BINS); + if (b < 0) b = 0; + if (b >= HIST_BINS) b = HIST_BINS - 1; + s.hist[b] += 1.0f; + } + } + } + return s; +} + +void reorder_column(State& st, int src, int dst) { + if (src == dst) return; + auto it_s = std::find(st.col_order.begin(), st.col_order.end(), src); + auto it_d = std::find(st.col_order.begin(), st.col_order.end(), dst); + if (it_s == st.col_order.end() || it_d == st.col_order.end()) return; + int si = (int)(it_s - st.col_order.begin()); + int di = (int)(it_d - st.col_order.begin()); + int v = st.col_order[si]; + st.col_order.erase(st.col_order.begin() + si); + // Insertar en `di`: cubre ambos sentidos. Para si insert(di) lo + // coloca al final de la posicion logica original de dst. Para si>di + // (drag izquierda) dst sigue en di y src queda antes. + if (di > (int)st.col_order.size()) di = (int)st.col_order.size(); + st.col_order.insert(st.col_order.begin() + di, v); +} + +std::string csv_escape(const char* s) { + if (!s) return ""; + bool needs = false; + for (const char* p = s; *p; ++p) { + if (*p == ',' || *p == '"' || *p == '\n' || *p == '\r') { needs = true; break; } + } + if (!needs) return std::string(s); + std::string out; out.reserve(std::strlen(s) + 4); + out += '"'; + for (const char* p = s; *p; ++p) { + if (*p == '"') out += '"'; + out += *p; + } + out += '"'; + return out; +} + +namespace { +std::string tsv_sanitize(const char* s) { + std::string out; + if (!s) return out; + out.reserve(std::strlen(s)); + for (const char* p = s; *p; ++p) { + char ch = *p; + if (ch == '\t' || ch == '\n' || ch == '\r') ch = ' '; + out += ch; + } + return out; +} +} // anon + +std::string build_tsv(const char* const* cells, int rows, int cols, + const char* const* headers, + const std::vector& col_order, + const std::vector& col_visible, + const std::vector& visible_rows, + int view_row_lo, int view_row_hi, + int view_col_lo, int view_col_hi) +{ + if (col_order.empty() || visible_rows.empty()) return ""; + int rmin = std::min(view_row_lo, view_row_hi); + int rmax = std::max(view_row_lo, view_row_hi); + int cmin = std::min(view_col_lo, view_col_hi); + int cmax = std::max(view_col_lo, view_col_hi); + rmin = std::max(0, rmin); + rmax = std::min((int)visible_rows.size() - 1, rmax); + cmin = std::max(0, cmin); + cmax = std::min((int)col_order.size() - 1, cmax); + + std::string out; + bool first = true; + for (int oc = cmin; oc <= cmax; ++oc) { + int c = col_order[oc]; + if (c < 0 || c >= cols) continue; + if (c < (int)col_visible.size() && !col_visible[c]) continue; + if (!first) out += '\t'; + out += tsv_sanitize(headers[c]); + first = false; + } + out += '\n'; + for (int ri = rmin; ri <= rmax; ++ri) { + int r = visible_rows[ri]; + first = true; + for (int oc = cmin; oc <= cmax; ++oc) { + int c = col_order[oc]; + if (c < 0 || c >= cols) continue; + if (c < (int)col_visible.size() && !col_visible[c]) continue; + if (!first) out += '\t'; + out += tsv_sanitize(cells[r * cols + c]); + first = false; + } + out += '\n'; + } + return out; +} + +std::string build_csv(const char* const* cells, int rows, int cols, + const char* const* headers, + const std::vector& col_order, + const std::vector& col_visible, + const std::vector& visible_rows) +{ + if (col_order.empty()) return ""; + std::string out; + bool first = true; + for (int oc = 0; oc < (int)col_order.size(); ++oc) { + int c = col_order[oc]; + if (c < 0 || c >= cols) continue; + if (c < (int)col_visible.size() && !col_visible[c]) continue; + if (!first) out += ','; + out += csv_escape(headers[c]); + first = false; + } + out += '\n'; + for (int r : visible_rows) { + first = true; + for (int oc = 0; oc < (int)col_order.size(); ++oc) { + int c = col_order[oc]; + if (c < 0 || c >= cols) continue; + if (c < (int)col_visible.size() && !col_visible[c]) continue; + if (!first) out += ','; + out += csv_escape(cells[r * cols + c]); + first = false; + } + out += '\n'; + } + return out; +} + +int find_open_bracket(const char* buf, int len, int cursor, std::string& filter_text) { + filter_text.clear(); + if (!buf || cursor <= 0 || cursor > len) return -1; + for (int i = cursor - 1; i >= 0; --i) { + char c = buf[i]; + if (c == ']' || c == '\n') return -1; // already closed or new line + if (c == '[') { + filter_text.assign(buf + i + 1, cursor - i - 1); + return i; + } + } + return -1; +} + +std::string insert_column_ref(const std::string& src, int start, int cursor, + const std::string& name, int& new_cursor) +{ + if (start < 0 || start > (int)src.size() || cursor < start || cursor > (int)src.size()) { + new_cursor = cursor; + return src; + } + std::string replacement = "[" + name + "]"; + std::string out; + out.reserve(src.size() - (cursor - start) + replacement.size()); + out.append(src, 0, start); + out += replacement; + out.append(src, cursor, std::string::npos); + new_cursor = start + (int)replacement.size(); + return out; +} + +// ---------------------------------------------------------------------------- +// TQL stage compute +// ---------------------------------------------------------------------------- + +const char* agg_fn_name(AggFn f) { + switch (f) { + case AggFn::Count: return "count"; + case AggFn::Sum: return "sum"; + case AggFn::Avg: return "avg"; + case AggFn::Min: return "min"; + case AggFn::Max: return "max"; + case AggFn::Distinct: return "distinct"; + case AggFn::Stddev: return "stddev"; + case AggFn::Median: return "median"; + case AggFn::P25: return "p25"; + case AggFn::P75: return "p75"; + case AggFn::P90: return "p90"; + case AggFn::P99: return "p99"; + case AggFn::Percentile: return "percentile"; + } + return "?"; +} + +std::string aggregation_alias(const Aggregation& a) { + if (!a.alias.empty()) return a.alias; + if (a.fn == AggFn::Count) return "count"; + if (a.fn == AggFn::Percentile) { + int pct = (int)(a.arg * 100.0 + 0.5); + char buf[128]; + std::snprintf(buf, sizeof(buf), "p%d_%s", pct, a.col.c_str()); + return buf; + } + std::string out = agg_fn_name(a.fn); + out += '_'; + out += a.col; + return out; +} + +ColumnType aggregation_type(const Aggregation& a, + const std::vector& in_headers, + const std::vector& in_types) +{ + if (a.fn == AggFn::Count || a.fn == AggFn::Distinct) return ColumnType::Int; + if (a.fn == AggFn::Min || a.fn == AggFn::Max) { + for (size_t i = 0; i < in_headers.size(); ++i) { + if (in_headers[i] == a.col && i < in_types.size()) return in_types[i]; + } + return ColumnType::String; + } + return ColumnType::Float; +} + +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; +} + +std::vector apply_filters(const char* const* cells, int rows, int cols, + const std::vector& filters) +{ + std::vector out; + out.reserve(rows); + for (int r = 0; r < rows; ++r) { + bool keep = true; + for (const auto& f : filters) { + if (f.col < 0 || f.col >= cols) continue; + const char* cell = cells[r * cols + f.col]; + if (!compare(cell, f.value.c_str(), f.op)) { keep = false; break; } + } + if (keep) out.push_back(r); + } + return out; +} + +namespace { + +int find_col(const std::vector& headers, const std::string& name) { + for (size_t i = 0; i < headers.size(); ++i) if (headers[i] == name) return (int)i; + return -1; +} + +// Compara dos cells para sort: numerico si ambos parseables, sino lexical. +int cmp_cells(const char* a, const char* b) { + if (!a) a = ""; if (!b) b = ""; + double na, nb; + bool num = parse_number(a, na) && parse_number(b, nb); + if (num) return (na < nb) ? -1 : (na > nb ? 1 : 0); + return std::strcmp(a, b); +} + +void apply_sorts(std::vector& row_idx, + const char* const* cells, int cols, + const std::vector& headers, + const std::vector& sorts) +{ + if (sorts.empty()) return; + std::vector sort_cols(sorts.size()); + for (size_t i = 0; i < sorts.size(); ++i) sort_cols[i] = find_col(headers, sorts[i].col); + std::sort(row_idx.begin(), row_idx.end(), [&](int a, int b){ + for (size_t i = 0; i < sorts.size(); ++i) { + int sc = sort_cols[i]; + if (sc < 0) continue; + int c = cmp_cells(cells[a * cols + sc], cells[b * cols + sc]); + if (c != 0) return sorts[i].desc ? (c > 0) : (c < 0); + } + return false; + }); +} + +double percentile_value(std::vector& v, double p) { + if (v.empty()) return 0.0; + std::sort(v.begin(), v.end()); + double idx = p * (v.size() - 1); + size_t lo = (size_t)idx; + size_t hi = std::min(lo + 1, v.size() - 1); + double t = idx - lo; + return v[lo] * (1.0 - t) + v[hi] * t; +} + +double compute_agg_numeric(AggFn fn, std::vector& vals, double arg) { + if (vals.empty()) return 0.0; + switch (fn) { + case AggFn::Sum: { + double s = 0; for (double v : vals) s += v; return s; + } + case AggFn::Avg: { + double s = 0; for (double v : vals) s += v; return s / vals.size(); + } + case AggFn::Min: { + double m = vals[0]; for (double v : vals) if (v < m) m = v; return m; + } + case AggFn::Max: { + double m = vals[0]; for (double v : vals) if (v > m) m = v; return m; + } + case AggFn::Stddev: { + double s = 0; for (double v : vals) s += v; + double mean = s / vals.size(); + double var = 0; for (double v : vals) { double d = v - mean; var += d * d; } + return std::sqrt(var / vals.size()); + } + case AggFn::Median: return percentile_value(vals, 0.50); + case AggFn::P25: return percentile_value(vals, 0.25); + case AggFn::P75: return percentile_value(vals, 0.75); + case AggFn::P90: return percentile_value(vals, 0.90); + case AggFn::P99: return percentile_value(vals, 0.99); + case AggFn::Percentile: return percentile_value(vals, arg); + default: return 0.0; + } +} + +std::string format_double(double v) { + char buf[64]; + long long iv = (long long)v; + if ((double)iv == v) std::snprintf(buf, sizeof(buf), "%lld", iv); + else std::snprintf(buf, sizeof(buf), "%.4g", v); + return buf; +} + +} // anon + +StageOutput compute_stage(const char* const* in_cells, int in_rows, int in_cols, + const std::vector& in_headers, + const std::vector& in_types, + const Stage& stage) +{ + StageOutput out; + auto visible = apply_filters(in_cells, in_rows, in_cols, stage.filters); + + bool grouped = !stage.breakouts.empty() || !stage.aggregations.empty(); + + if (!grouped) { + // Passthrough: misma forma, filtrado + ordenado. + out.cols = in_cols; + out.headers = in_headers; + out.types = in_types; + // Sort sobre visible. + apply_sorts(visible, in_cells, in_cols, in_headers, stage.sorts); + out.rows = (int)visible.size(); + out.cells.reserve((size_t)out.rows * in_cols); + for (int r : visible) { + for (int c = 0; c < in_cols; ++c) out.cells.push_back(in_cells[r * in_cols + c]); + } + return out; + } + + // Grouped: agrupa visible por valores de breakout, calcula aggregations. + std::vector break_cols(stage.breakouts.size()); + for (size_t i = 0; i < stage.breakouts.size(); ++i) { + break_cols[i] = find_col(in_headers, stage.breakouts[i]); + } + + auto make_key = [&](int r) -> std::string { + std::string k; + for (size_t i = 0; i < break_cols.size(); ++i) { + if (i > 0) k += '\x1f'; // separador unit-separator (no aparece en datos) + int bc = break_cols[i]; + if (bc < 0) continue; + const char* v = in_cells[r * in_cols + bc]; + k += (v ? v : ""); + } + return k; + }; + + // Mantenemos orden de aparicion para estabilidad pre-sort. + std::unordered_map key_to_group; + std::vector group_keys; // canonical, no usado salvo debug + std::vector> group_rows; // indices en in_cells por grupo + std::vector> group_breakvals; // valores break por grupo + for (int r : visible) { + std::string k = make_key(r); + auto it = key_to_group.find(k); + int gi; + if (it == key_to_group.end()) { + gi = (int)group_rows.size(); + key_to_group.emplace(k, gi); + group_keys.push_back(k); + group_rows.emplace_back(); + std::vector bv(break_cols.size(), ""); + for (size_t i = 0; i < break_cols.size(); ++i) { + int bc = break_cols[i]; + bv[i] = (bc >= 0) ? in_cells[r * in_cols + bc] : ""; + } + group_breakvals.push_back(std::move(bv)); + } else gi = it->second; + group_rows[gi].push_back(r); + } + + // Headers + types del output: breakouts + aggregation aliases. + int out_cols = (int)stage.breakouts.size() + (int)stage.aggregations.size(); + out.cols = out_cols; + out.headers.reserve(out_cols); + out.types.reserve(out_cols); + for (size_t i = 0; i < stage.breakouts.size(); ++i) { + out.headers.push_back(stage.breakouts[i]); + int bc = break_cols[i]; + out.types.push_back((bc >= 0 && bc < (int)in_types.size()) + ? in_types[bc] : ColumnType::String); + } + for (const auto& a : stage.aggregations) { + out.headers.push_back(aggregation_alias(a)); + out.types.push_back(aggregation_type(a, in_headers, in_types)); + } + + // Compute aggregation values por grupo. Reservamos backing con tamaño exacto + // para que los punteros .c_str() no se invaliden. + int n_groups = (int)group_rows.size(); + out.cell_backing.reserve((size_t)n_groups * stage.aggregations.size() + 16); + + auto store_backing = [&](const std::string& s) -> const char* { + out.cell_backing.push_back(s); + return out.cell_backing.back().c_str(); + }; + + // Construimos cells por grupo (filas no ordenadas todavia). + std::vector flat; + flat.reserve((size_t)n_groups * out_cols); + for (int gi = 0; gi < n_groups; ++gi) { + // breakout values: punteros directos a in_cells (estables). + for (size_t i = 0; i < stage.breakouts.size(); ++i) { + flat.push_back(group_breakvals[gi][i]); + } + // aggregations + for (const auto& a : stage.aggregations) { + if (a.fn == AggFn::Count) { + flat.push_back(store_backing(format_double((double)group_rows[gi].size()))); + continue; + } + if (a.fn == AggFn::Distinct) { + int ac = find_col(in_headers, a.col); + if (ac < 0) { flat.push_back(store_backing("0")); continue; } + std::unordered_set uniq; + for (int r : group_rows[gi]) { + const char* v = in_cells[r * in_cols + ac]; + if (v && *v) uniq.insert(v); + } + flat.push_back(store_backing(format_double((double)uniq.size()))); + continue; + } + int ac = find_col(in_headers, a.col); + if (ac < 0) { flat.push_back(store_backing("")); continue; } + // min/max sobre strings preserva tipo + if ((a.fn == AggFn::Min || a.fn == AggFn::Max) && + ac < (int)in_types.size() && + (in_types[ac] == ColumnType::String || in_types[ac] == ColumnType::Date)) + { + const char* best = nullptr; + for (int r : group_rows[gi]) { + const char* v = in_cells[r * in_cols + ac]; + if (!v || !*v) continue; + if (!best) { best = v; continue; } + int c = std::strcmp(v, best); + if ((a.fn == AggFn::Min && c < 0) || (a.fn == AggFn::Max && c > 0)) best = v; + } + flat.push_back(best ? best : store_backing("")); + continue; + } + std::vector vals; + vals.reserve(group_rows[gi].size()); + for (int r : group_rows[gi]) { + const char* v = in_cells[r * in_cols + ac]; + if (!v || !*v) continue; + double d; + if (parse_number(v, d)) vals.push_back(d); + } + double agg_val = compute_agg_numeric(a.fn, vals, a.arg); + flat.push_back(store_backing(format_double(agg_val))); + } + } + + // Sort sobre los n_groups segun stage.sorts (col-name lookup en out.headers). + std::vector grp_idx(n_groups); + for (int i = 0; i < n_groups; ++i) grp_idx[i] = i; + apply_sorts(grp_idx, flat.data(), out_cols, out.headers, stage.sorts); + + out.rows = n_groups; + out.cells.reserve((size_t)n_groups * out_cols); + for (int gi : grp_idx) { + for (int c = 0; c < out_cols; ++c) { + out.cells.push_back(flat[gi * out_cols + c]); + } + } + return out; +} + +// ---------------------------------------------------------------------------- +// ViewMode helpers +// ---------------------------------------------------------------------------- +struct ViewModeInfo { + ViewMode m; + const char* token; + const char* label; + int min_cols; + bool needs_num; + bool needs_cat; + bool needs_agg; +}; + +static const ViewModeInfo kViewModes[] = { + { ViewMode::Table, "table", "Table", 1, false, false, false }, + { ViewMode::Bar, "bar", "Bar (horizontal)", 2, true, true, true }, + { ViewMode::Column, "column", "Column (vertical)", 2, true, true, true }, + { ViewMode::GroupedBar, "grouped_bar", "Grouped bar", 2, true, true, true }, + { ViewMode::StackedBar, "stacked_bar", "Stacked bar", 2, true, true, true }, + { ViewMode::Line, "line", "Line", 1, true, false, false }, + { ViewMode::Area, "area", "Area", 1, true, false, false }, + { ViewMode::Stairs, "stairs", "Stairs", 1, true, false, false }, + { ViewMode::Scatter, "scatter", "Scatter", 2, true, false, false }, + { ViewMode::Bubble, "bubble", "Bubble", 3, true, false, false }, + { ViewMode::Histogram, "histogram", "Histogram", 1, true, false, false }, + { ViewMode::Histogram2D, "hist2d", "Histogram 2D", 2, true, false, false }, + { ViewMode::Heatmap, "heatmap", "Heatmap", 1, true, false, false }, + { ViewMode::BoxPlot, "boxplot", "Box plot", 2, true, true, false }, + { ViewMode::Stem, "stem", "Stem", 1, true, false, false }, + { ViewMode::ErrorBars, "errorbars", "Error bars", 2, true, false, false }, + { ViewMode::Pie, "pie", "Pie", 2, true, true, true }, + { ViewMode::Donut, "donut", "Donut", 2, true, true, true }, + { ViewMode::Funnel, "funnel", "Funnel", 2, true, true, true }, + { ViewMode::Waterfall, "waterfall", "Waterfall", 1, true, false, true }, + { ViewMode::KPI, "kpi", "KPI (single)", 1, true, false, true }, + { ViewMode::KPIGrid, "kpi_grid", "KPI grid", 1, true, false, true }, + { ViewMode::Candlestick, "candlestick", "Candlestick (OHLC)", 4, true, false, false }, + { ViewMode::Radar, "radar", "Radar", 2, true, true, false }, +}; +static const int kViewModesN = (int)(sizeof(kViewModes) / sizeof(kViewModes[0])); + +const char* view_mode_token(ViewMode m) { + for (int i = 0; i < kViewModesN; ++i) if (kViewModes[i].m == m) return kViewModes[i].token; + return "table"; +} + +const char* view_mode_label(ViewMode m) { + for (int i = 0; i < kViewModesN; ++i) if (kViewModes[i].m == m) return kViewModes[i].label; + return "Table"; +} + +ViewMode view_mode_from_token(const char* s) { + if (!s) return ViewMode::Table; + for (int i = 0; i < kViewModesN; ++i) { + if (std::strcmp(kViewModes[i].token, s) == 0) return kViewModes[i].m; + } + return ViewMode::Table; +} + +int view_mode_min_cols(ViewMode m) { + for (int i = 0; i < kViewModesN; ++i) if (kViewModes[i].m == m) return kViewModes[i].min_cols; + return 1; +} + +bool view_mode_needs_numeric(ViewMode m) { + for (int i = 0; i < kViewModesN; ++i) if (kViewModes[i].m == m) return kViewModes[i].needs_num; + return false; +} + +bool view_mode_needs_category(ViewMode m) { + for (int i = 0; i < kViewModesN; ++i) if (kViewModes[i].m == m) return kViewModes[i].needs_cat; + return false; +} + +bool view_mode_needs_aggregation(ViewMode m) { + for (int i = 0; i < kViewModesN; ++i) if (kViewModes[i].m == m) return kViewModes[i].needs_agg; + return false; +} + +const ViewMode* all_view_modes(int* n_out) { + static ViewMode arr[64]; + static bool init = false; + if (!init) { + for (int i = 0; i < kViewModesN; ++i) arr[i] = kViewModes[i].m; + init = true; + } + if (n_out) *n_out = kViewModesN; + return arr; +} + +// ---------------------------------------------------------------------------- +// Joins +// ---------------------------------------------------------------------------- +int resolve_main_idx(const std::vector& tables, const std::string& main_source) { + if (tables.empty()) return -1; + if (main_source.empty()) return 0; + for (size_t i = 0; i < tables.size(); ++i) { + if (tables[i].name == main_source) return (int)i; + } + return 0; +} + +const char* join_strategy_token(JoinStrategy s) { + switch (s) { + case JoinStrategy::Left: return "left"; + case JoinStrategy::Inner: return "inner"; + case JoinStrategy::Right: return "right"; + case JoinStrategy::Full: return "full"; + } + return "left"; +} + +JoinStrategy join_strategy_from_token(const char* s) { + if (!s) return JoinStrategy::Left; + if (std::strcmp(s, "inner") == 0) return JoinStrategy::Inner; + if (std::strcmp(s, "right") == 0) return JoinStrategy::Right; + if (std::strcmp(s, "full") == 0) return JoinStrategy::Full; + return JoinStrategy::Left; +} + +const char* join_strategy_label(JoinStrategy s) { + switch (s) { + case JoinStrategy::Left: return "left-join"; + case JoinStrategy::Inner: return "inner-join"; + case JoinStrategy::Right: return "right-join"; + case JoinStrategy::Full: return "full-join"; + } + return "left-join"; +} + +namespace { + +int find_col_idx(const std::vector& hdrs, const std::string& name) { + for (size_t i = 0; i < hdrs.size(); ++i) if (hdrs[i] == name) return (int)i; + return -1; +} + +std::string make_key(const char* const* cells, int row, int cols, + const std::vector& key_cols) { + std::string k; + for (int c : key_cols) { + if (c < 0 || c >= cols) { k += "\x1f|"; continue; } + const char* s = cells[row * cols + c]; + k += (s ? s : ""); + k += "\x1f"; // separator + } + return k; +} + +} // anon + +StageOutput join_tables(const char* const* left_cells, int left_rows, int left_cols, + const std::vector& left_headers, + const std::vector& left_types, + const TableInput& right, + const Join& jn) +{ + StageOutput out; + + // Resolver indices de keys en left y right. + std::vector lk_idx, rk_idx; + for (const auto& p : jn.on) { + lk_idx.push_back(find_col_idx(left_headers, p.first)); + rk_idx.push_back(find_col_idx(right.headers, p.second)); + } + + // Resolver fields del derecho a incluir. + std::vector right_fields; + if (jn.fields.empty()) { + for (int i = 0; i < right.cols; ++i) right_fields.push_back(i); + } else { + for (const auto& f : jn.fields) { + int i = find_col_idx(right.headers, f); + if (i >= 0) right_fields.push_back(i); + } + } + + // Build output headers + types: left + alias.right_field. + out.cols = left_cols + (int)right_fields.size(); + out.headers.reserve(out.cols); + out.types.reserve(out.cols); + for (int c = 0; c < left_cols; ++c) { + out.headers.push_back(c < (int)left_headers.size() ? left_headers[c] : ""); + out.types.push_back(c < (int)left_types.size() ? left_types[c] : ColumnType::Auto); + } + for (int rc : right_fields) { + std::string prefixed = jn.alias.empty() ? right.headers[rc] : (jn.alias + "." + right.headers[rc]); + out.headers.push_back(std::move(prefixed)); + out.types.push_back(rc < (int)right.types.size() ? right.types[rc] : ColumnType::Auto); + } + + // Hash right rows por key. + std::unordered_map> right_idx; + right_idx.reserve(right.rows); + for (int r = 0; r < right.rows; ++r) { + right_idx[make_key(right.cells, r, right.cols, rk_idx)].push_back(r); + } + + // Marca cuales right rows fueron usados (para right/full). + std::vector right_matched(right.rows, false); + + // Backing strings para celdas. + out.cell_backing.reserve((size_t)(left_rows + right.rows) * out.cols); + + auto append_left_row = [&](int lr) { + for (int c = 0; c < left_cols; ++c) { + const char* s = left_cells[lr * left_cols + c]; + out.cell_backing.emplace_back(s ? s : ""); + } + }; + auto append_left_empty = [&]() { + for (int c = 0; c < left_cols; ++c) out.cell_backing.emplace_back(""); + }; + auto append_right_row = [&](int rr) { + for (int rc : right_fields) { + const char* s = right.cells[rr * right.cols + rc]; + out.cell_backing.emplace_back(s ? s : ""); + } + }; + auto append_right_empty = [&]() { + for (int rc : right_fields) { (void)rc; out.cell_backing.emplace_back(""); } + }; + + bool include_left = (jn.strategy == JoinStrategy::Left || jn.strategy == JoinStrategy::Inner || + jn.strategy == JoinStrategy::Full); + bool keep_unmatched_left = (jn.strategy == JoinStrategy::Left || jn.strategy == JoinStrategy::Full); + bool keep_unmatched_right = (jn.strategy == JoinStrategy::Right || jn.strategy == JoinStrategy::Full); + + int row_count = 0; + + if (include_left || jn.strategy == JoinStrategy::Right) { + for (int lr = 0; lr < left_rows; ++lr) { + std::string k = make_key(left_cells, lr, left_cols, lk_idx); + auto it = right_idx.find(k); + if (it == right_idx.end() || it->second.empty()) { + if (keep_unmatched_left) { + append_left_row(lr); + append_right_empty(); + ++row_count; + } + continue; + } + for (int rr : it->second) { + append_left_row(lr); + append_right_row(rr); + right_matched[rr] = true; + ++row_count; + } + } + } + + if (keep_unmatched_right) { + for (int rr = 0; rr < right.rows; ++rr) { + if (right_matched[rr]) continue; + append_left_empty(); + append_right_row(rr); + ++row_count; + } + } + + out.rows = row_count; + + // Punteros tras llenar backing. + out.cells.reserve(out.cell_backing.size()); + for (auto& s : out.cell_backing) out.cells.push_back(s.c_str()); + return out; +} + } // namespace data_table diff --git a/playground/tables/data_table_logic.h b/playground/tables/data_table_logic.h index 91d7aab..9c5c290 100644 --- a/playground/tables/data_table_logic.h +++ b/playground/tables/data_table_logic.h @@ -3,13 +3,52 @@ #pragma once #include +#include #include namespace data_table { -enum class Op { Eq, Neq, Gt, Gte, Lt, Lte }; +enum class Op { + Eq, Neq, Gt, Gte, Lt, Lte, + Contains, NotContains, StartsWith, EndsWith +}; const char* op_label(Op o); +bool op_is_string_only(Op o); +// ---------------------------------------------------------------------------- +// Column types - declarado por caller con fallback a auto-detect. +// ---------------------------------------------------------------------------- +enum class ColumnType { + Auto, String, Int, Float, Bool, Date, Json +}; + +const char* column_type_name(ColumnType t); +const char* column_type_icon(ColumnType t); // UTF-8 Tabler icon + +// Ops permitidos para cada tipo. Devuelve vector ordenado. +std::vector ops_for_type(ColumnType t); + +// Auto-detect via sample: escanea hasta `sample_n` celdas no-vacias. +ColumnType auto_detect_type(const char* const* cells, int rows, int cols, + int col, int sample_n = 64); + +// Tipo efectivo: si declared != Auto -> declared; else auto_detect. +ColumnType effective_type(ColumnType declared, + const char* const* cells, int rows, int cols, int col); + +// Derived column: inmutable. Dos modos: +// 1) Retipo puro: source_col >= 0, formula == "". Cells del origen. +// 2) Formula: source_col == -1, formula no vacia. Eval por Lua. +struct DerivedColumn { + int source_col = -1; + ColumnType type = ColumnType::String; + std::string name; + std::string formula; // "" = retipado puro; resto = body Lua + int lua_id = -1; // referencia en lua_engine; -1 si no compilado + std::string compile_error; +}; + +// Filter movido aqui (antes era despues de State) porque TQL Stage lo necesita. struct Filter { int col; Op op; @@ -19,15 +58,232 @@ struct Filter { struct ColorRule { int col; std::string equals; - unsigned int color; // ImU32 (ABGR para ImGui) + unsigned int color; }; +// ---------------------------------------------------------------------------- +// TQL (Table Query Language) — stage model. Ver docs/TQL.md. +// ---------------------------------------------------------------------------- +enum class AggFn { + Count, Sum, Avg, Min, Max, Distinct, Stddev, + Median, P25, P75, P90, P99, Percentile +}; + +const char* agg_fn_name(AggFn f); + +struct Aggregation { + AggFn fn = AggFn::Count; + std::string col; // ignorado para Count + double arg = 0.0; // para Percentile (0..1) + std::string alias; // vacio -> auto-generado via aggregation_alias() +}; + +struct SortClause { + std::string col; + bool desc = false; +}; + +// Stage: layer de TQL. Stage 0 = Raw (sin breakouts/aggregations). +// Stage 1+ pueden agrupar. Cada stage consume output del anterior. +struct Stage { + std::vector filters; + std::vector derived; // expressions de este stage + std::vector breakouts; // col names del INPUT de este stage + std::vector aggregations; + std::vector sorts; +}; + +// Pure: alias por defecto cuando agg.alias esta vacio. +// count -> "count" +// distinct col -> "distinct_" +// percentile p -> "p_" (ej. p95_size_kb) +// resto -> "_" (ej. avg_size_kb) +std::string aggregation_alias(const Aggregation& a); + +// Pure: tipo del output de la aggregation. +// count, distinct -> Int +// sum, avg, stddev, +// median, p*, percentile -> Float +// min, max -> mismo tipo que la col origen +ColumnType aggregation_type(const Aggregation& a, + const std::vector& in_headers, + const std::vector& in_types); + +// Output de compute_stage. Posee `cell_backing` (strings nuevos para +// resultados agregados) y `cells` (punteros row-major a backing o a +// `in_cells` original para passthrough). +struct StageOutput { + std::vector cell_backing; + std::vector cells; + int rows = 0; + int cols = 0; + std::vector headers; + std::vector types; +}; + +// Pure: ejecuta un Stage sobre los cells de entrada. Aplica filter -> (group+agg|passthrough) -> sort. +StageOutput compute_stage(const char* const* in_cells, int in_rows, int in_cols, + const std::vector& in_headers, + const std::vector& in_types, + const Stage& stage); + +// Pure: aplica filtros usando headers para resolver f.col (que ahora es +// indice en el array de in_headers, no del dataset original). Devuelve +// indices de filas que pasan. +std::vector apply_filters(const char* const* cells, int rows, int cols, + const std::vector& filters); + +// Pure: helper para drill-down. Devuelve un Filter Op::Eq sobre col_idx con +// el value indicado. col_idx es indice en los headers del INPUT del stage +// previo (donde se va a aplicar el filtro). +Filter make_drill_filter(int col_idx, const std::string& value); + +// ---------------------------------------------------------------------------- +// ViewMode: tipo de visualizacion a renderizar sobre el output del stage activo. +// "Table" siempre disponible. Resto requiere ciertos tipos de columnas. +// ---------------------------------------------------------------------------- +enum class ViewMode { + Table, + // Bars + Bar, // horizontal bars: 1 cat + 1 num + Column, // vertical bars: 1 cat + 1 num + GroupedBar, // 1 cat + N num (side-by-side) + StackedBar, // 1 cat + N num (stacked) + // Lines / area + Line, // X + 1..N Y series + Area, // shaded to y=0 + Stairs, // step plot + // Points + Scatter, // X + Y + Bubble, // X + Y + size + // Distribution + Histogram, // 1 num + Histogram2D, // 2 num + Heatmap, // matrix from breakouts + BoxPlot, // 1 cat + 1 num (min/p25/p50/p75/max per group) + // Stems / signals + Stem, + ErrorBars, + // Composition + Pie, + Donut, + Funnel, // ordered descending bars + Waterfall, // running sum + // Single values + KPI, // big text + label + KPIGrid, // all aggregations as cards + // Specialized + Candlestick, // OHLC: time + open + high + low + close + Radar, // multi-axis (1 cat + N num) +}; + +const char* view_mode_token(ViewMode m); // "table", "bar", ... +const char* view_mode_label(ViewMode m); // "Table", "Bar (horizontal)", ... +ViewMode view_mode_from_token(const char* s); +int view_mode_min_cols(ViewMode m); +bool view_mode_needs_numeric(ViewMode m); +bool view_mode_needs_category(ViewMode m); +// Requiere stage agrupado (breakout+aggregation). Si user esta en stage 0 con +// uno de estos, conviene auto-promote a stage 1. +bool view_mode_needs_aggregation(ViewMode m); + +// Lista completa de modos para el selector UI (orden de display). +const ViewMode* all_view_modes(int* n_out); + +// ---------------------------------------------------------------------------- +// Joins (MBQL-style). Ver issue 0078. +// ---------------------------------------------------------------------------- +enum class JoinStrategy { Left, Inner, Right, Full }; +const char* join_strategy_token(JoinStrategy s); +JoinStrategy join_strategy_from_token(const char* s); +const char* join_strategy_label(JoinStrategy s); + +// Tabla extra pasada al render() para joins. Owner externo (caller). +struct TableInput { + std::string name; // identificador estable (matchea Join.source) + std::vector headers; + std::vector types; + const char* const* cells = nullptr; // row-major, headers.size() cols x rows filas + int rows = 0; + int cols = 0; +}; + +// Join clause: une la tabla actual con `source` por las parejas `on`, +// prefijando las cols del derecho con `alias.`. +struct Join { + std::string alias; + std::string source; + std::vector> on; // {left_col, right_col} + JoinStrategy strategy = JoinStrategy::Left; + std::vector fields; // vacio = all del derecho +}; + +// Pure: resuelve indice del main entre `tables` segun `main_source`. +// Vacio -> 0. Nombre desconocido -> 0. tables vacio -> -1. +int resolve_main_idx(const std::vector& tables, const std::string& main_source); + +// Pure: aplica un join sobre dos tablas. Resultado: StageOutput con +// `headers` = left + `.` (filtrado por fields si no vacio). +StageOutput join_tables(const char* const* left_cells, int left_rows, int left_cols, + const std::vector& left_headers, + const std::vector& left_types, + const TableInput& right, + const Join& jn); + +// ViewConfig: overrides manuales de auto-detect para la vista activa. +// Campos vacios -> auto. Si col name no existe en output, viz cae a auto. +struct ViewConfig { + std::string x_col; // single: scatter, line, hist2d + std::vector y_cols; // 1..N: line/area/bar/etc + std::string size_col; // bubble + std::string cat_col; // bar/pie/funnel/box override + unsigned int primary_color = 0; // 0 = ImPlot auto + int hist_bins = 0; // 0 = Sturges + float pie_radius = 0.0f; // 0 = default + bool show_legend = true; + bool show_markers = false; // line/area markers + bool locked = false; // disable pan/zoom + mutable bool fit_request = false; // consumed by viz::render +}; + +// VizPanel: viz adicional sobre el mismo StageOutput. State.display + viz_config +// es el panel 0 (siempre visible); extra_panels son los aniadidos por el user. +struct VizPanel { + ViewMode display = ViewMode::Bar; + ViewConfig config; + // Memoria del ultimo non-Table display para toggle Table<->View. + mutable ViewMode last_non_table = ViewMode::Bar; +}; + +// State: stage pipeline + viz globales. +// +// `stages` siempre tiene tamaño >= 1 (auto-init en compute_visible_rows / render +// si esta vacio: se crea stages[0] vacio). Stage 0 es Raw (filters + derived + +// sorts; SIN breakouts/aggregations). Stages 1+ pueden agrupar. +// +// `active_stage` = indice del stage cuyo output se renderiza. +// `col_visible/col_order/color_rules` aplican al output del stage activo. struct State { - std::vector filters; - std::vector color_rules; - std::vector col_visible; // size = col_count; auto-init en render - int sort_col = -1; // -1 = sin sort - bool sort_desc = false; + std::vector stages; + int active_stage = 0; + ViewMode display = ViewMode::Table; + ViewConfig viz_config; + std::vector extra_panels; + std::vector joins; // aplicado antes de stages[0] + std::string main_source; // name de TableInput a usar como main; vacio -> tables[0] + + std::vector color_rules; + std::vector col_visible; // size = effective_cols del stage activo + std::vector col_order; // permutacion [0..effective_cols) + + // --- Compat helpers: shortcuts a stages[0] (Raw) --- + // Util tras refactor para tests / accesos puntuales. Garantizan stages[0] + // existe (lo crean vacio si no). + Stage& raw(); + const Stage& raw() const; + Stage& active(); + const Stage& active_const() const; + void ensure_stage0(); }; // Parse "1.23" -> 1.23, true. False si la celda no es numero completo. @@ -41,4 +297,69 @@ std::vector compute_visible_rows(const char* const* cells, int rows, int cols, const State& st); +// Pure: muta col_order de st para colocar `src` en la posicion (en orden visual) +// donde estaba `dst`. No-op si src == dst o cualquiera fuera del array. +void reorder_column(State& st, int src, int dst); + +// Pure: dado un buffer y posicion de cursor, busca el `[` abierto sin cerrar +// mas reciente. Devuelve su indice (o -1 si ninguno). Rellena `filter_text` +// con los caracteres entre `[` y cursor. +// Para autocomplete de formulas: cuando el usuario teclea `[` el ImGui callback +// detecta esto y muestra un popup con cols disponibles. +int find_open_bracket(const char* buf, int len, int cursor, std::string& filter_text); + +// Pure: reemplaza src[start..cursor) por "[name]". Devuelve nuevo string y +// actualiza `new_cursor` a la posicion despues del `]`. +std::string insert_column_ref(const std::string& src, int start, int cursor, + const std::string& name, int& new_cursor); + +// CSV: escapa una celda segun RFC 4180 (wrap en " si contiene , " o newline). +std::string csv_escape(const char* s); + +// Construye TSV de un rect de seleccion. Headers SIEMPRE incluidos. +// view_row_lo/hi: indices en visible_rows. +// view_col_lo/hi: indices en col_order. Cols ocultas se omiten. +std::string build_tsv(const char* const* cells, int rows, int cols, + const char* const* headers, + const std::vector& col_order, + const std::vector& col_visible, + const std::vector& visible_rows, + int view_row_lo, int view_row_hi, + int view_col_lo, int view_col_hi); + +// Construye CSV (full visible view). Headers incluidos, cells escapados. +std::string build_csv(const char* const* cells, int rows, int cols, + const char* const* headers, + const std::vector& col_order, + const std::vector& col_visible, + const std::vector& visible_rows); + +struct ColStats { + int total = 0; // filas escaneadas + int empty_count = 0; // cells == "" o null + int unique_count = 0; // distintas (cap configurable) + bool unique_capped = false; // true si se alcanzo el cap + bool numeric = false; // true si todas las cells no-vacias parsean como numero + int numeric_count = 0; + double min = 0; + double max = 0; + double sum = 0; + double mean = 0; + double p25 = 0; + double p50 = 0; + double p75 = 0; + std::vector hist; // bins (HIST_BINS) si numeric + std::vector> top_categories; // top 8 por count desc +}; + +constexpr int HIST_BINS = 24; + +// Pure: escanea una columna y devuelve estadisticas. `unique_cap` corta el +// conteo de unicos si excede (para datasets de millones). 0 = sin cap. +// Si `indices != nullptr` y `n_indices > 0`, recorre solo las filas indicadas +// (uso tipico: stats sobre filas visibles post-filtro). +ColStats compute_column_stats(const char* const* cells, int rows, int cols, + int col, int unique_cap = 100000, + const int* indices = nullptr, int n_indices = 0); + } // namespace data_table diff --git a/playground/tables/e2e_run.sh b/playground/tables/e2e_run.sh old mode 100644 new mode 100755 diff --git a/playground/tables/lua_engine.cpp b/playground/tables/lua_engine.cpp new file mode 100644 index 0000000..c978d81 --- /dev/null +++ b/playground/tables/lua_engine.cpp @@ -0,0 +1,574 @@ +#include "lua_engine.h" + +extern "C" { +#include "lua.h" +#include "lualib.h" +#include "lauxlib.h" +} + +#include +#include +#include +#include + +namespace lua_engine { + +struct Engine { + lua_State* L = nullptr; + std::vector ctx_stack; + std::vector visiting_derived; +}; + +namespace { + +Engine* g_engine = nullptr; + +Engine* engine_from_state(lua_State* L) { + return *static_cast(lua_getextraspace(L)); +} + +RowCtx* current_ctx(lua_State* L) { + Engine* e = engine_from_state(L); + if (!e || e->ctx_stack.empty()) return nullptr; + return e->ctx_stack.back(); +} + +// --------------------------------------------------------------------------- +// Push de cell respetando tipo declarado: +// Int/Float -> number (integer si exacto) +// Bool -> boolean (true/false/1/0); en otro caso push string +// Date/String/Json/Auto -> string +// Si types_orig == nullptr -> heuristica: parse_number; si parsea -> number. +// --------------------------------------------------------------------------- +void push_typed(lua_State* L, const char* v, data_table::ColumnType t) { + if (!v || !*v) { lua_pushnil(L); return; } + using data_table::ColumnType; + using data_table::parse_number; + if (t == ColumnType::Int) { + double d; + if (parse_number(v, d)) { + long long iv = (long long)d; + if ((double)iv == d) lua_pushinteger(L, (lua_Integer)iv); + else lua_pushnumber (L, (lua_Number)d); + } else lua_pushstring(L, v); + return; + } + if (t == ColumnType::Float) { + double d; + if (parse_number(v, d)) { + long long iv = (long long)d; + if ((double)iv == d) lua_pushinteger(L, (lua_Integer)iv); + else lua_pushnumber (L, (lua_Number)d); + } else lua_pushstring(L, v); + return; + } + if (t == ColumnType::Bool) { + if (std::strcmp(v, "true") == 0 || std::strcmp(v, "1") == 0) lua_pushboolean(L, 1); + else if (std::strcmp(v, "false") == 0 || std::strcmp(v, "0") == 0) lua_pushboolean(L, 0); + else lua_pushstring(L, v); + return; + } + if (t == ColumnType::Auto) { + // Sin tipo declarado: heuristica. parse_number -> number, else string. + double d; + if (parse_number(v, d)) { + long long iv = (long long)d; + if ((double)iv == d) lua_pushinteger(L, (lua_Integer)iv); + else lua_pushnumber (L, (lua_Number)d); + } else lua_pushstring(L, v); + return; + } + // String / Date / Json + lua_pushstring(L, v); +} + +// Fwd: para recursion en row_index. +std::string eval_internal(Engine* e, int id, const RowCtx& ctx, std::string* err_out); + +int row_index(lua_State* L) { + Engine* eng = engine_from_state(L); + RowCtx* ctx = current_ctx(L); + if (!ctx) { lua_pushnil(L); return 1; } + + using data_table::ColumnType; + auto get_orig_type = [&](int c) -> ColumnType { + if (ctx->types_orig && c < ctx->n_types_orig) return ctx->types_orig[c]; + return ColumnType::Auto; + }; + + if (lua_type(L, 2) == LUA_TSTRING) { + const char* key = lua_tostring(L, 2); + if (ctx->name_to_col) { + auto it = ctx->name_to_col->find(key); + if (it != ctx->name_to_col->end()) { + int col = it->second; + push_typed(L, ctx->cells[ctx->row * ctx->orig_cols + col], get_orig_type(col)); + return 1; + } + } + if (ctx->derived_name_to_idx && ctx->derived) { + auto it = ctx->derived_name_to_idx->find(key); + if (it != ctx->derived_name_to_idx->end()) { + int didx = it->second; + if (didx < 0 || didx >= (int)ctx->derived->size()) { + lua_pushnil(L); return 1; + } + // cycle check + for (int v : eng->visiting_derived) { + if (v == didx) { lua_pushnil(L); return 1; } + } + const auto& d = (*ctx->derived)[didx]; + if (d.formula.empty()) { + // retipo puro + if (d.source_col < 0 || d.source_col >= ctx->orig_cols) { + lua_pushnil(L); return 1; + } + push_typed(L, ctx->cells[ctx->row * ctx->orig_cols + d.source_col], d.type); + } else if (d.lua_id < 0) { + lua_pushnil(L); + } else { + eng->visiting_derived.push_back(didx); + std::string err; + std::string r = eval_internal(eng, d.lua_id, *ctx, &err); + eng->visiting_derived.pop_back(); + push_typed(L, r.c_str(), d.type); + } + return 1; + } + } + lua_pushnil(L); + return 1; + } + if (lua_type(L, 2) == LUA_TNUMBER) { + int idx = (int)lua_tointeger(L, 2); + if (idx >= 1 && idx <= ctx->orig_cols) { + int col = idx - 1; + push_typed(L, ctx->cells[ctx->row * ctx->orig_cols + col], get_orig_type(col)); + return 1; + } + } + lua_pushnil(L); + return 1; +} + +// --- fn.* builtins --- +int b_upper(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + std::string out(s); + for (char& c : out) if (c >= 'a' && c <= 'z') c -= 32; + lua_pushlstring(L, out.data(), out.size()); + return 1; +} +int b_lower(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + std::string out(s); + for (char& c : out) if (c >= 'A' && c <= 'Z') c += 32; + lua_pushlstring(L, out.data(), out.size()); + return 1; +} +int b_length(lua_State* L) { + if (lua_isnil(L, 1)) { lua_pushinteger(L, 0); return 1; } + const char* s = luaL_checkstring(L, 1); + lua_pushinteger(L, (lua_Integer)std::strlen(s)); + return 1; +} +int b_substring(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + int start = (int)luaL_checkinteger(L, 2); + int len = (int)luaL_optinteger(L, 3, -1); + int slen = (int)std::strlen(s); + if (start < 1) start = 1; + if (start > slen) { lua_pushlstring(L, "", 0); return 1; } + int from = start - 1; + int take = (len < 0) ? slen - from : len; + if (from + take > slen) take = slen - from; + lua_pushlstring(L, s + from, take); + return 1; +} +int b_contains(lua_State* L) { + const char* h = luaL_checkstring(L, 1); + const char* n = luaL_checkstring(L, 2); + lua_pushboolean(L, std::strstr(h, n) != nullptr); + return 1; +} +int b_starts_with(lua_State* L) { + const char* h = luaL_checkstring(L, 1); + const char* n = luaL_checkstring(L, 2); + size_t ln = std::strlen(n); + lua_pushboolean(L, std::strncmp(h, n, ln) == 0); + return 1; +} +int b_ends_with(lua_State* L) { + const char* h = luaL_checkstring(L, 1); + const char* n = luaL_checkstring(L, 2); + size_t lh = std::strlen(h), ln = std::strlen(n); + lua_pushboolean(L, ln <= lh && std::strcmp(h + lh - ln, n) == 0); + return 1; +} +int b_replace(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + const char* find = luaL_checkstring(L, 2); + const char* repl = luaL_checkstring(L, 3); + std::string out; + size_t flen = std::strlen(find); + if (flen == 0) { lua_pushstring(L, s); return 1; } + for (const char* p = s; *p; ) { + if (std::strncmp(p, find, flen) == 0) { out += repl; p += flen; } + else { out += *p++; } + } + lua_pushlstring(L, out.data(), out.size()); + return 1; +} +int b_trim(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') ++s; + const char* e = s + std::strlen(s); + while (e > s && (e[-1] == ' ' || e[-1] == '\t' || e[-1] == '\n' || e[-1] == '\r')) --e; + lua_pushlstring(L, s, e - s); + return 1; +} +int b_concat(lua_State* L) { + int n = lua_gettop(L); + std::string out; + for (int i = 1; i <= n; ++i) { + size_t sl = 0; + const char* s = luaL_tolstring(L, i, &sl); + out.append(s, sl); + lua_pop(L, 1); + } + lua_pushlstring(L, out.data(), out.size()); + return 1; +} +int b_to_number(lua_State* L) { + if (lua_isnumber(L, 1)) { lua_pushvalue(L, 1); return 1; } + const char* s = luaL_checkstring(L, 1); + char* end = nullptr; + double v = std::strtod(s, &end); + if (end == s) { lua_pushnil(L); return 1; } + lua_pushnumber(L, v); + return 1; +} +int b_to_string(lua_State* L) { luaL_tolstring(L, 1, nullptr); return 1; } +int b_to_bool(lua_State* L) { + if (lua_isboolean(L, 1)) { lua_pushvalue(L, 1); return 1; } + const char* s = luaL_optstring(L, 1, ""); + lua_pushboolean(L, std::strcmp(s, "true") == 0 || std::strcmp(s, "1") == 0); + return 1; +} +int b_is_null(lua_State* L) { lua_pushboolean(L, lua_isnil(L, 1)); return 1; } +int b_is_empty(lua_State* L) { + if (lua_isnil(L, 1)) { lua_pushboolean(L, 1); return 1; } + const char* s = luaL_optstring(L, 1, ""); + lua_pushboolean(L, *s == 0); + return 1; +} +int b_coalesce(lua_State* L) { + int n = lua_gettop(L); + for (int i = 1; i <= n; ++i) { + if (!lua_isnil(L, i)) { lua_pushvalue(L, i); return 1; } + } + lua_pushnil(L); + return 1; +} +int b_parse_date(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + if (std::strlen(s) < 10) { lua_pushnil(L); return 1; } + int y, m, d; + if (std::sscanf(s, "%d-%d-%d", &y, &m, &d) != 3) { lua_pushnil(L); return 1; } + lua_createtable(L, 0, 3); + lua_pushinteger(L, y); lua_setfield(L, -2, "year"); + lua_pushinteger(L, m); lua_setfield(L, -2, "month"); + lua_pushinteger(L, d); lua_setfield(L, -2, "day"); + return 1; +} +int b_year(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + int y; if (std::sscanf(s, "%d", &y) != 1) { lua_pushnil(L); return 1; } + lua_pushinteger(L, y); return 1; +} +int b_month(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + int y, m; if (std::sscanf(s, "%d-%d", &y, &m) != 2) { lua_pushnil(L); return 1; } + lua_pushinteger(L, m); return 1; +} +int b_day(lua_State* L) { + const char* s = luaL_checkstring(L, 1); + int y, m, d; if (std::sscanf(s, "%d-%d-%d", &y, &m, &d) != 3) { lua_pushnil(L); return 1; } + lua_pushinteger(L, d); return 1; +} + +void apply_sandbox(lua_State* L) { + const char* nuke[] = { "io", "require", "loadfile", "dofile", "load", + "package", "debug", nullptr }; + for (int i = 0; nuke[i]; ++i) { + lua_pushnil(L); + lua_setglobal(L, nuke[i]); + } + lua_getglobal(L, "os"); + if (lua_istable(L, -1)) { + lua_createtable(L, 0, 4); + const char* keep[] = {"date", "time", "difftime", "clock", nullptr}; + for (int i = 0; keep[i]; ++i) { + lua_getfield(L, -2, keep[i]); + lua_setfield(L, -2, keep[i]); + } + lua_setglobal(L, "os"); + } + lua_pop(L, 1); +} + +void register_builtins(lua_State* L) { + lua_createtable(L, 0, 24); + #define R(name, fn) lua_pushcfunction(L, fn); lua_setfield(L, -2, name); + R("upper", b_upper); + R("lower", b_lower); + R("length", b_length); + R("substring", b_substring); + R("contains", b_contains); + R("starts_with", b_starts_with); + R("ends_with", b_ends_with); + R("replace", b_replace); + R("trim", b_trim); + R("concat", b_concat); + R("to_number", b_to_number); + R("to_string", b_to_string); + R("to_bool", b_to_bool); + R("is_null", b_is_null); + R("is_empty", b_is_empty); + R("coalesce", b_coalesce); + R("parse_date", b_parse_date); + R("year", b_year); + R("month", b_month); + R("day", b_day); + #undef R + lua_setglobal(L, "fn"); +} + +void install_row_metatable(lua_State* L) { + luaL_newmetatable(L, "fn_row_meta"); + lua_pushcfunction(L, row_index); + lua_setfield(L, -2, "__index"); + lua_pop(L, 1); +} + +// --------------------------------------------------------------------------- +// Preprocesador: [col] -> row["col"] respetando strings y comentarios. +// Auto-prepend `return` si la formula es expresion suelta. +// --------------------------------------------------------------------------- +bool ident_start(unsigned char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c >= 0x80; +} +// Para nombres de cols dentro de [name]: permite espacios para "col with space" +// y '.' para futuro `alias.col` post-join (fase 9 — issue 0078). +bool ident_cont(unsigned char c) { + return ident_start(c) || (c >= '0' && c <= '9') || c == ' ' || c == '.'; +} +// Para boundary de keywords Lua: NO permite espacio. +bool word_char(unsigned char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' || c >= 0x80; +} + +bool kw_at(const std::string& s, size_t i, const char* kw) { + size_t k = std::strlen(kw); + if (i + k > s.size()) return false; + if (s.compare(i, k, kw) != 0) return false; + if (i + k == s.size()) return true; + unsigned char nc = (unsigned char)s[i + k]; + return !word_char(nc); +} + +bool needs_auto_return(const std::string& body) { + size_t i = 0; + while (i < body.size()) { + char c = body[i]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { ++i; continue; } + // skip short comment + if (c == '-' && i + 1 < body.size() && body[i+1] == '-') { + // long comment? + if (i + 3 < body.size() && body[i+2] == '[' && body[i+3] == '[') { + size_t j = i + 4; + while (j + 1 < body.size() && !(body[j] == ']' && body[j+1] == ']')) ++j; + i = (j + 1 < body.size()) ? j + 2 : body.size(); + continue; + } + while (i < body.size() && body[i] != '\n') ++i; + continue; + } + break; + } + if (i >= body.size()) return false; + const char* kws[] = {"return","if","for","while","do","local","repeat","function", nullptr}; + for (int k = 0; kws[k]; ++k) if (kw_at(body, i, kws[k])) return false; + return true; +} + +std::string brackets_pass(const std::string& src) { + std::string out; + out.reserve(src.size() + 16); + size_t i = 0; + while (i < src.size()) { + char c = src[i]; + // strings + if (c == '"' || c == '\'') { + char q = c; + out += c; ++i; + while (i < src.size()) { + char d = src[i]; + out += d; ++i; + if (d == '\\' && i < src.size()) { out += src[i++]; continue; } + if (d == q) break; + if (d == '\n') break; + } + continue; + } + // comentario corto / largo + if (c == '-' && i + 1 < src.size() && src[i+1] == '-') { + // long: --[[ ... ]] + if (i + 3 < src.size() && src[i+2] == '[' && src[i+3] == '[') { + out.append(src, i, 4); i += 4; + while (i + 1 < src.size() && !(src[i] == ']' && src[i+1] == ']')) { + out += src[i++]; + } + if (i + 1 < src.size()) { out += src[i++]; out += src[i++]; } + continue; + } + // short + while (i < src.size() && src[i] != '\n') { out += src[i++]; } + continue; + } + // long string [[ ... ]] + if (c == '[' && i + 1 < src.size() && src[i+1] == '[') { + out.append(src, i, 2); i += 2; + while (i + 1 < src.size() && !(src[i] == ']' && src[i+1] == ']')) { + out += src[i++]; + } + if (i + 1 < src.size()) { out += src[i++]; out += src[i++]; } + continue; + } + // bracket col-ref [name] + if (c == '[') { + // peek if next is valid ident_start + if (i + 1 < src.size() && ident_start((unsigned char)src[i+1])) { + size_t j = i + 1; + while (j < src.size() && src[j] != ']' && src[j] != '\n') { + if (!ident_cont((unsigned char)src[j])) { j = std::string::npos; break; } + ++j; + } + if (j != std::string::npos && j < src.size() && src[j] == ']') { + std::string name(src, i + 1, j - i - 1); + // trim trailing space + while (!name.empty() && name.back() == ' ') name.pop_back(); + out += "row[\""; + out += name; + out += "\"]"; + i = j + 1; + continue; + } + } + } + out += c; + ++i; + } + return out; +} + +} // anon + +std::string preprocess(const std::string& body) { + std::string pre = brackets_pass(body); + if (needs_auto_return(pre)) return "return " + pre; + return pre; +} + +namespace { + +std::string eval_internal(Engine* e, int id, const RowCtx& ctx, std::string* err_out) { + if (!e || !e->L || id < 0) { + if (err_out) *err_out = "invalid handle"; + return ""; + } + lua_State* L = e->L; + e->ctx_stack.push_back(const_cast(&ctx)); + lua_rawgeti(L, LUA_REGISTRYINDEX, id); + lua_newuserdata(L, 1); + luaL_setmetatable(L, "fn_row_meta"); + int rc = lua_pcall(L, 1, 1, 0); + e->ctx_stack.pop_back(); + if (rc != LUA_OK) { + if (err_out) *err_out = lua_tostring(L, -1) ? lua_tostring(L, -1) : "runtime error"; + lua_pop(L, 1); + return ""; + } + std::string out; + if (lua_isnil(L, -1)) out = ""; + else { + size_t n = 0; + const char* s = luaL_tolstring(L, -1, &n); + out.assign(s, n); + lua_pop(L, 1); + } + lua_pop(L, 1); + return out; +} + +} // anon + +Engine* get() { + if (g_engine) return g_engine; + g_engine = new Engine(); + g_engine->L = luaL_newstate(); + luaL_openlibs(g_engine->L); + *static_cast(lua_getextraspace(g_engine->L)) = g_engine; + apply_sandbox(g_engine->L); + register_builtins(g_engine->L); + install_row_metatable(g_engine->L); + return g_engine; +} + +void shutdown() { + if (!g_engine) return; + lua_close(g_engine->L); + delete g_engine; + g_engine = nullptr; +} + +int compile(Engine* e, const std::string& body, std::string* err_out) { + if (!e || !e->L) { if (err_out) *err_out = "engine null"; return -1; } + lua_State* L = e->L; + std::string final_body = preprocess(body); + std::string wrapped = "return function(row)\n" + final_body + "\nend"; + if (luaL_loadbufferx(L, wrapped.data(), wrapped.size(), "formula", "t") != LUA_OK) { + if (err_out) *err_out = lua_tostring(L, -1) ? lua_tostring(L, -1) : "parse error"; + lua_pop(L, 1); + return -1; + } + if (lua_pcall(L, 0, 1, 0) != LUA_OK) { + if (err_out) *err_out = lua_tostring(L, -1) ? lua_tostring(L, -1) : "compile error"; + lua_pop(L, 1); + return -1; + } + if (!lua_isfunction(L, -1)) { + if (err_out) *err_out = "formula did not produce a function"; + lua_pop(L, 1); + return -1; + } + int ref = luaL_ref(L, LUA_REGISTRYINDEX); + return ref; +} + +void release(Engine* e, int id) { + if (!e || !e->L || id < 0) return; + luaL_unref(e->L, LUA_REGISTRYINDEX, id); +} + +std::string eval(Engine* e, int id, const RowCtx& ctx, std::string* err_out) { + return eval_internal(e, id, ctx, err_out); +} + +lua_State* raw_state() { + Engine* e = get(); + return e ? e->L : nullptr; +} + +} // namespace lua_engine diff --git a/playground/tables/lua_engine.h b/playground/tables/lua_engine.h new file mode 100644 index 0000000..daf4e37 --- /dev/null +++ b/playground/tables/lua_engine.h @@ -0,0 +1,61 @@ +// Lua 5.4 wrapper para formulas de columnas custom del playground tables. +// +// Features: +// - Sandbox medio: io/require/dofile fuera; os reducido a date/time/diff/clock. +// - Builtins fn.* (~20 funciones). +// - Sintaxis [col_name] preprocesada a row["col_name"]. +// - Auto-`return` si la formula es expresion suelta sin keyword inicial. +// - Type-aware push: row.x devuelve number si la col es Int/Float, boolean +// si Bool, string en el resto (Date/String/Json). Nil si vacia. +// - UTF-8 ok en nombres de columnas dentro de []. +// - Comentarios y string literals preservados por el preprocesador. +// - Llamadas recursivas: un derived col puede referenciar a otro derived col; +// ciclos cortados con nil. +#pragma once + +#include "data_table_logic.h" + +#include +#include +#include + +// Forward declaration del C struct de Lua (definido en lua.h). +struct lua_State; + +namespace lua_engine { + +struct Engine; + +Engine* get(); +void shutdown(); + +int compile(Engine* e, const std::string& body, std::string* err_out); +void release(Engine* e, int id); + +struct RowCtx { + const char* const* cells = nullptr; + int orig_cols = 0; + int row = 0; + const std::vector* header_names = nullptr; + const std::unordered_map* name_to_col = nullptr; + + // Tipos declarados/auto-detect de las cols originales. nullptr -> heuristica. + const data_table::ColumnType* types_orig = nullptr; + int n_types_orig = 0; + + // Derived cols + lookup por nombre (incluye retipo puro y formulas). + const std::vector* derived = nullptr; + const std::unordered_map* derived_name_to_idx = nullptr; +}; + +std::string eval(Engine* e, int id, const RowCtx& ctx, std::string* err_out); + +// Helper expuesto para tests: preprocesa `[col]` -> `row["col"]` respetando +// strings y comentarios. Tambien aplica auto-return. +std::string preprocess(const std::string& body); + +// Acceso al lua_State subyacente. Uso restringido: tql.cpp parsea chunks +// (return { ... }) y walks tablas. NO usar para nada que rompa el sandbox. +::lua_State* raw_state(); + +} // namespace lua_engine diff --git a/playground/tables/main.cpp b/playground/tables/main.cpp index 9e8ace5..7bbf9e2 100644 --- a/playground/tables/main.cpp +++ b/playground/tables/main.cpp @@ -1,89 +1,175 @@ -// Playground tables: iterador de la fn `data_table` antes de promoverla al -// registry y migrar las apps C++ que hoy usan `ImGui::BeginTable` raw. - #include "app_base.h" #include "imgui.h" #include "core/logger.h" #include "data_table.h" +#include +#include +#include +#include +#include #include namespace { -struct Row { - const char* name; - const char* lang; - const char* domain; - const char* purity; - const char* description; +// --------------------------------------------------------------------------- +// Dataset generador. Filas se generan con valores deterministas en funcion del +// indice (semilla = i). Strings repetidas (lang/domain/purity/tested) usan +// interned literals -> sin coste de memoria por fila. +// --------------------------------------------------------------------------- + +struct Dataset { + int rows = 0; + int cols = 10; + std::vector backing; // dynamic strings (name, version, deps, size, cov, date) + std::vector cells; // row-major pointers }; -const std::vector& sample_rows() { - static const std::vector rows = { - {"filter_slice", "go", "core", "pure", "Filtra slice con predicado"}, - {"map_slice", "go", "core", "pure", "Aplica f a cada elemento"}, - {"reduce_slice", "go", "core", "pure", "Fold con acumulador"}, - {"sma", "py", "finance", "pure", "Simple moving average"}, - {"ema", "py", "finance", "pure", "Exponential moving average"}, - {"rsi", "py", "finance", "pure", "Relative strength index"}, - {"table_view", "cpp", "viz", "pure", "Tabla ImGui actual del registry"}, - {"line_plot", "cpp", "viz", "pure", "ImPlot line wrapper"}, - {"scatter_plot", "cpp", "viz", "pure", "ImPlot scatter wrapper"}, - {"bar_chart", "cpp", "viz", "pure", "ImPlot bar wrapper"}, - {"heatmap", "cpp", "viz", "pure", "ImPlot heatmap wrapper"}, - {"sqlite_open", "go", "infra", "impure", "Open SQLite con WAL+FK"}, - {"http_json_response", "go", "infra", "impure", "Helper JSON response"}, - {"http_parse_body", "go", "infra", "impure", "Parse JSON body"}, - {"rsync_deploy", "bash", "infra", "impure", "rsync local -> remoto"}, - {"systemd_install", "go", "infra", "impure", "Sube unit + enable + start"}, - {"systemd_restart", "go", "infra", "impure", "Restart servicio remoto"}, - {"jupyter_discover", "py", "notebook", "impure", "Descubre instancias Jupyter"}, - {"jupyter_exec", "py", "notebook", "impure", "Ejecuta celda y vuelca output"}, - {"docker_pull_image", "go", "infra", "impure", "docker pull con timeout"}, - {"graph_force_layout", "cpp", "viz", "pure", "Force-directed CPU"}, - {"graph_force_layout_gpu","cpp", "viz", "pure", "Force-directed GPU (compute)"}, - {"sql_workbench", "cpp", "core", "impure", "Workbench SQL embebido"}, - {"text_editor", "cpp", "core", "impure", "Editor de texto con highlighting"}, - {"icon_font", "cpp", "core", "impure", "Carga tabler-icons.ttf"}, +const char* const* dataset_cells(const Dataset& d) { return d.cells.data(); } + +std::shared_ptr build_dataset(int rows) { + auto d = std::make_shared(); + d->rows = rows; + d->cols = 10; + + static const char* langs[] = {"go", "py", "cpp", "bash", "ts"}; + static const char* domains[] = {"core", "viz", "infra", "finance", "notebook", "shell"}; + static const char* puritys[] = {"pure", "impure"}; + static const char* bools[] = {"true", "false"}; + + // Reserve antes de pushear -> punteros .c_str() estables. + d->backing.reserve((size_t)rows * 6 + 16); + d->cells.reserve((size_t)rows * 10); + + auto add = [&](const std::string& s) -> const char* { + d->backing.push_back(s); + return d->backing.back().c_str(); }; - return rows; + + char buf[40]; + for (int i = 0; i < rows; ++i) { + std::snprintf(buf, sizeof(buf), "fn_%07d", i); + const char* name = add(buf); + + const char* lang = langs[i % 5]; + const char* domain = domains[i % 6]; + const char* purity = puritys[i % 2]; + + std::snprintf(buf, sizeof(buf), "%d", (i % 5) + 1); + const char* vmaj = add(buf); + std::snprintf(buf, sizeof(buf), "%d", i % 7); + const char* deps = add(buf); + std::snprintf(buf, sizeof(buf), "%.2f", ((i * 31) % 10000) / 100.0); + const char* size = add(buf); + std::snprintf(buf, sizeof(buf), "%.1f", (i % 1001) / 10.0); + const char* cov = add(buf); + const char* tst = bools[(i * 3) % 2]; + + int y = 2024 + (i % 3); + int m = 1 + (i % 12); + int day = 1 + (i % 28); + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", y, m, day); + const char* dt = add(buf); + + d->cells.push_back(name); + d->cells.push_back(lang); + d->cells.push_back(domain); + d->cells.push_back(purity); + d->cells.push_back(vmaj); + d->cells.push_back(deps); + d->cells.push_back(size); + d->cells.push_back(cov); + d->cells.push_back(tst); + d->cells.push_back(dt); + } + return d; } -const char* const* flatten_cells(int& rows, int& cols) { - static std::vector flat; - static bool built = false; - if (!built) { - const auto& src = sample_rows(); - flat.reserve(src.size() * 5); - for (const auto& r : src) { - flat.push_back(r.name); - flat.push_back(r.lang); - flat.push_back(r.domain); - flat.push_back(r.purity); - flat.push_back(r.description); - } - built = true; - } - rows = (int)sample_rows().size(); - cols = 5; - return flat.data(); +std::shared_ptr& current_dataset() { + static std::shared_ptr ds; + if (!ds) ds = build_dataset(100); + return ds; } } // namespace void render() { static data_table::State st; - if (ImGui::Begin("Tables Playground - data_table v0.1")) { + if (ImGui::Begin("Tables Playground - data_table v0.5")) { ImGui::TextWrapped( - "Iteracion 1: sort real al pulsar header, click en celda -> popup operador " - "(=, !=, >, >=, <, <=) -> chip removible. Click derecho header: filter input, " - "conditional color, hide column, show/hide columns."); + "v0.5: + en chip-row anade filtro a cualquier col. Show stats muestra " + "0/uniq/mean/min/max por header. Clipper virtualiza render -> 1M filas a 60 FPS."); + + ImGui::Separator(); + ImGui::Text("Dataset size:"); + ImGui::SameLine(); + const int sizes[] = {100, 10000, 100000, 1000000}; + const char* labels[] = {"100", "10K", "100K", "1M"}; + for (size_t i = 0; i < sizeof(sizes)/sizeof(sizes[0]); ++i) { + if (i > 0) ImGui::SameLine(); + bool is_active = (current_dataset()->rows == sizes[i]); + if (is_active) ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(80, 120, 80, 255)); + if (ImGui::SmallButton(labels[i])) { + current_dataset() = build_dataset(sizes[i]); + st = data_table::State{}; // reset filtros/sort/orden al cambiar dataset + } + if (is_active) ImGui::PopStyleColor(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(actual: %d filas)", current_dataset()->rows); ImGui::Separator(); - static const char* headers[] = {"name", "lang", "domain", "purity", "description"}; - int rows = 0, cols = 0; - const char* const* cells = flatten_cells(rows, cols); - data_table::render("##registry_sample", headers, cols, cells, rows, st); + static const char* headers[] = { + "name", "lang", "domain", "purity", + "version_major", "deps_count", "size_kb", "coverage_pct", + "tested", "updated_at" + }; + static const data_table::ColumnType types[] = { + data_table::ColumnType::String, // name + data_table::ColumnType::String, // lang + data_table::ColumnType::String, // domain + data_table::ColumnType::String, // purity + data_table::ColumnType::Int, // version_major + data_table::ColumnType::Int, // deps_count + data_table::ColumnType::Float, // size_kb + data_table::ColumnType::Float, // coverage_pct + data_table::ColumnType::Bool, // tested + data_table::ColumnType::Date, // updated_at + }; + // Tabla extra para demo de joins (fase 9). + static const char* lang_info_cells[] = { + "go", "compiled", "2009", + "py", "interp", "1991", + "rust", "compiled", "2010", + "ts", "interp", "2012", + "bash", "shell", "1989", + "lua", "interp", "1993", + }; + static data_table::TableInput lang_info; + if (lang_info.name.empty()) { + lang_info.name = "lang_info"; + lang_info.headers = {"lang", "family", "year"}; + lang_info.types = {data_table::ColumnType::String, + data_table::ColumnType::String, + data_table::ColumnType::Int}; + lang_info.cells = lang_info_cells; + lang_info.rows = 6; + lang_info.cols = 3; + } + + const auto& d = *current_dataset(); + static data_table::TableInput main_t; + main_t.name = "fn_registry"; + main_t.headers = {"name", "lang", "domain", "purity", + "version_major", "deps_count", "size_kb", "coverage_pct", + "tested", "updated_at"}; + main_t.types = std::vector(types, types + 10); + main_t.cells = dataset_cells(d); + main_t.rows = d.rows; + main_t.cols = d.cols; + + std::vector tables = { main_t, lang_info }; + data_table::render("##bigdata", tables, st); } ImGui::End(); } @@ -92,11 +178,12 @@ void render() { int main() { return fn::run_app({ .title = "Tables Playground", - .width = 1280, - .height = 800, + .width = 1400, + .height = 900, .about = {.name = "tables_playground", - .version = "0.2.0", - .description = "Playground para iterar mejoras sobre table_view antes de promover al registry."}, + .version = "0.5.0", + .description = "Playground data_table: + add filter, stats por columna, " + "clipper para datasets de millones."}, .log = {.file_path = "tables_playground.log", .level = static_cast(fn_log::Level::Info)} }, render); diff --git a/playground/tables/self_test.cpp b/playground/tables/self_test.cpp index 0b8ffc5..08d3927 100644 --- a/playground/tables/self_test.cpp +++ b/playground/tables/self_test.cpp @@ -7,10 +7,14 @@ // Exit 0 = todos los checks pasan, 1 = falla. #include "data_table_logic.h" +#include "lua_engine.h" +#include "tql.h" #include #include #include +#include +#include #include namespace { @@ -27,6 +31,36 @@ void check(bool cond, const char* name) { using namespace data_table; +// Test helpers: imitan la API antigua sort_col/sort_desc sobre el nuevo +// modelo de SortClause-by-name. Usan la convencion "@" para indices +// posicionales (compatible con compute_visible_rows). +namespace { +void set_sort_idx(State& st, int idx, bool desc) { + st.ensure_stage0(); + st.stages[0].sorts.clear(); + if (idx < 0) return; + char buf[16]; std::snprintf(buf, sizeof(buf), "@%d", idx); + st.stages[0].sorts.push_back({buf, desc}); +} +void set_sort_desc(State& st, bool desc) { + st.ensure_stage0(); + if (st.stages[0].sorts.empty()) return; + st.stages[0].sorts.front().desc = desc; +} +int sort_col_idx(const State& st) { + const Stage& s = st.raw(); + if (s.sorts.empty()) return -1; + const std::string& c = s.sorts.front().col; + if (c.size() < 2 || c[0] != '@') return -1; + return std::atoi(c.c_str() + 1); +} +bool sort_col_desc(const State& st) { + const Stage& s = st.raw(); + if (s.sorts.empty()) return false; + return s.sorts.front().desc; +} +} // namespace + int main() { // --- parse_number --- double v = 0; @@ -61,56 +95,1962 @@ int main() { "a","4", }; State st; - st.filters.push_back({0, Op::Eq, "a"}); + st.raw().filters.push_back({0, Op::Eq, "a"}); auto rows = compute_visible_rows(cells, 4, 2, st); check(rows.size() == 2 && rows[0] == 0 && rows[1] == 3, "filter col0 = a"); // --- filter numerico --- - st.filters.clear(); - st.filters.push_back({1, Op::Gt, "2"}); + st.raw().filters.clear(); + st.raw().filters.push_back({1, Op::Gt, "2"}); rows = compute_visible_rows(cells, 4, 2, st); check(rows.size() == 2 && rows[0] == 2 && rows[1] == 3, "filter col1 > 2"); // --- combinacion: > 1 AND col0 != b --- - st.filters.clear(); - st.filters.push_back({1, Op::Gt, "1"}); - st.filters.push_back({0, Op::Neq, "b"}); + st.raw().filters.clear(); + st.raw().filters.push_back({1, Op::Gt, "1"}); + st.raw().filters.push_back({0, Op::Neq, "b"}); rows = compute_visible_rows(cells, 4, 2, st); check(rows.size() == 2 && rows[0] == 2 && rows[1] == 3, "filter combinado AND"); // --- sort ascendente numerico --- - st.filters.clear(); - st.sort_col = 1; - st.sort_desc = false; + st.raw().filters.clear(); + set_sort_idx(st, 1, false); + set_sort_desc(st, false); rows = compute_visible_rows(cells, 4, 2, st); check(rows.size() == 4 && rows[0] == 0 && rows[3] == 3, "sort asc numerico"); // --- sort descendente numerico --- - st.sort_desc = true; + set_sort_desc(st, true); rows = compute_visible_rows(cells, 4, 2, st); check(rows.size() == 4 && rows[0] == 3 && rows[3] == 0, "sort desc numerico"); // --- sort lexical --- - st.sort_col = 0; - st.sort_desc = false; + set_sort_idx(st, 0, false); + set_sort_desc(st, false); rows = compute_visible_rows(cells, 4, 2, st); check(rows.size() == 4 && std::strcmp(cells[rows[0]*2], "a") == 0 && std::strcmp(cells[rows[3]*2], "c") == 0, "sort asc lexical"); // --- filter + sort combinado --- - st.sort_col = 1; - st.sort_desc = true; - st.filters.push_back({0, Op::Eq, "a"}); + set_sort_idx(st, 1, false); + set_sort_desc(st, true); + st.raw().filters.push_back({0, Op::Eq, "a"}); rows = compute_visible_rows(cells, 4, 2, st); check(rows.size() == 2 && rows[0] == 3 && rows[1] == 0, "filter+sort combinado"); // --- filter sobre columna inexistente: se ignora --- - st.filters.clear(); - st.filters.push_back({99, Op::Eq, "x"}); - st.sort_col = -1; + st.raw().filters.clear(); + st.raw().filters.push_back({99, Op::Eq, "x"}); + set_sort_idx(st, -1, false); rows = compute_visible_rows(cells, 4, 2, st); check(rows.size() == 4, "filter col fuera de rango ignorado"); + // --- col_order default identidad tras init --- + State st2; + st2.col_order = {0, 1, 2, 3}; + check(st2.col_order.size() == 4 && st2.col_order[0] == 0 && st2.col_order[3] == 3, + "col_order identidad"); + + // --- col_order no afecta compute_visible_rows (sort/filter trabajan sobre col dataset) --- + st2.col_order = {3, 2, 1, 0}; + set_sort_idx(st2, 1, false); + set_sort_desc(st2, false); + auto r2 = compute_visible_rows(cells, 4, 2, st2); + check(r2.size() == 4 && r2[0] == 0 && r2[3] == 3, + "col_order no afecta semantica sort/filter"); + + // --- reorder_column: drag DERECHA (si [1,2,0,3] + check(s.col_order.size() == 4 && + s.col_order[0] == 1 && s.col_order[1] == 2 && + s.col_order[2] == 0 && s.col_order[3] == 3, + "reorder derecha 0->2 = [1,2,0,3]"); + } + // --- reorder_column: drag IZQUIERDA (si>di) --- + { + State s; s.col_order = {0, 1, 2, 3}; + reorder_column(s, 3, 1); + // Esperado: 3 va a la posicion donde estaba 1 -> [0,3,1,2] + check(s.col_order.size() == 4 && + s.col_order[0] == 0 && s.col_order[1] == 3 && + s.col_order[2] == 1 && s.col_order[3] == 2, + "reorder izquierda 3->1 = [0,3,1,2]"); + } + // --- reorder_column: adyacente derecha --- + { + State s; s.col_order = {0, 1, 2, 3}; + reorder_column(s, 1, 2); + // 1->2: [0,2,1,3] + check(s.col_order[0] == 0 && s.col_order[1] == 2 && + s.col_order[2] == 1 && s.col_order[3] == 3, + "reorder adyacente derecha 1->2"); + } + // --- reorder_column: no-op src==dst --- + { + State s; s.col_order = {0, 1, 2, 3}; + reorder_column(s, 2, 2); + check(s.col_order[0] == 0 && s.col_order[1] == 1 && + s.col_order[2] == 2 && s.col_order[3] == 3, + "reorder no-op src==dst"); + } + // --- reorder_column: src o dst fuera del array --- + { + State s; s.col_order = {0, 1, 2}; + reorder_column(s, 99, 0); + check(s.col_order[0] == 0 && s.col_order[1] == 1 && s.col_order[2] == 2, + "reorder src fuera de rango = no-op"); + } + + // --- tipos mixtos: int / float / bool / date --- + const char* mixed[] = { + "alpha", "1", "1.2", "true", "2025-01-15", + "beta", "2", "0.9", "false", "2025-06-01", + "gamma", "10","0.45", "true", "2024-12-31", + }; + { + State s; s.raw().filters.push_back({1, Op::Gt, "2"}); // int col1 > 2 (numerico: 10>2) + auto r = compute_visible_rows(mixed, 3, 5, s); + check(r.size() == 1 && r[0] == 2, "filtro int numerico col1 > 2"); + } + { + State s; s.raw().filters.push_back({2, Op::Lt, "1.0"}); // float col2 < 1.0 + auto r = compute_visible_rows(mixed, 3, 5, s); + check(r.size() == 2 && r[0] == 1 && r[1] == 2, "filtro float col2 < 1.0"); + } + { + State s; s.raw().filters.push_back({3, Op::Eq, "true"}); // bool col3 == true + auto r = compute_visible_rows(mixed, 3, 5, s); + check(r.size() == 2 && r[0] == 0 && r[1] == 2, "filtro bool col3 == true"); + } + { + State s; s.raw().filters.push_back({4, Op::Gte, "2025-01-01"}); // date col4 >= 2025-01-01 (lexical) + auto r = compute_visible_rows(mixed, 3, 5, s); + check(r.size() == 2 && r[0] == 0 && r[1] == 1, "filtro date col4 >= 2025-01-01"); + } + { + State s; set_sort_idx(s, 2, false); set_sort_desc(s, true); // sort float desc + auto r = compute_visible_rows(mixed, 3, 5, s); + check(r.size() == 3 && r[0] == 0 && r[1] == 1 && r[2] == 2, + "sort float desc"); + } + { + State s; set_sort_idx(s, 4, false); set_sort_desc(s, false); // sort date asc (lexical) + auto r = compute_visible_rows(mixed, 3, 5, s); + check(r.size() == 3 && r[0] == 2 && r[1] == 0 && r[2] == 1, + "sort date asc cronologico"); + } + + // --- compute_column_stats --- + { + // Col numerica con un vacio + const char* m[] = { + "1", + "2", + "", + "5", + "5", + }; + // 5 rows x 1 col + const char* m_flat[] = {"1","2","","5","5"}; + auto s = compute_column_stats(m_flat, 5, 1, 0); + check(s.total == 5 && s.empty_count == 1, "stats: total + empty_count"); + check(s.numeric == true && s.numeric_count == 4, "stats: numeric flag + count"); + check(s.min == 1.0 && s.max == 5.0, "stats: min/max numerico"); + check(s.sum == 13.0, "stats: sum"); + check(s.mean == 13.0/4.0, "stats: mean ignora vacios"); + check(s.unique_count == 3, "stats: unique 3 (1,2,5)"); + } + { + // Col mixta: parsea como string (no numeric) + const char* m[] = {"go","py","go","cpp"}; + auto s = compute_column_stats(m, 4, 1, 0); + check(s.numeric == false, "stats: lexical no es numeric"); + check(s.unique_count == 3, "stats: unique 3 (go,py,cpp)"); + check(s.empty_count == 0, "stats: sin empties"); + } + { + // Cap de uniques + const char* m[] = {"a","b","c","d","e"}; + auto s = compute_column_stats(m, 5, 1, 0, /*unique_cap=*/2); + check(s.unique_capped == true, "stats: unique_capped flag"); + check(s.unique_count <= 2, "stats: unique respeta cap"); + } + { + // Bool col + const char* m[] = {"true","false","true","true"}; + auto s = compute_column_stats(m, 4, 1, 0); + check(s.numeric == false, "stats: bool no es numeric"); + check(s.unique_count == 2, "stats: bool unique = 2"); + } + { + // Col fuera de rango + const char* m[] = {"x"}; + auto s = compute_column_stats(m, 1, 1, 99); + check(s.total == 0, "stats: col fuera de rango devuelve vacio"); + } + { + // Percentiles sobre {1..9} + const char* m[] = {"1","2","3","4","5","6","7","8","9"}; + auto s = compute_column_stats(m, 9, 1, 0); + check(s.numeric && s.numeric_count == 9, "stats: 9 nums"); + check(s.p25 == 3.0, "stats: p25 = 3"); + check(s.p50 == 5.0, "stats: p50 = 5 (mediana)"); + check(s.p75 == 7.0, "stats: p75 = 7"); + check((int)s.hist.size() == HIST_BINS, "stats: hist tiene HIST_BINS bins"); + float sum = 0.f; for (float x : s.hist) sum += x; + check((int)sum == 9, "stats: hist suma = numeric_count"); + } + { + // Histograma con todos iguales -> bin central tiene todo + const char* m[] = {"5","5","5","5"}; + auto s = compute_column_stats(m, 4, 1, 0); + check(s.min == 5.0 && s.max == 5.0, "stats: min==max homogeneo"); + check(s.hist[HIST_BINS / 2] == 4.0f, "stats: hist degenerado pone todo en bin central"); + } + { + // Stats con indices: SOLO filas indicadas se contabilizan. + const char* m_flat[] = {"1","2","3","4","5","6","7","8","9"}; + int indices[] = {0, 2, 4}; // valores 1, 3, 5 + auto s = compute_column_stats(m_flat, 9, 1, 0, 100000, indices, 3); + check(s.total == 3, "stats(idx): total = n_indices"); + check(s.numeric_count == 3, "stats(idx): numeric_count"); + check(s.min == 1.0 && s.max == 5.0, "stats(idx): min/max sobre subset"); + check(s.mean == 3.0, "stats(idx): mean = 3"); + check(s.p50 == 3.0, "stats(idx): mediana subset"); + check(s.unique_count == 3, "stats(idx): unique subset"); + } + { + // Stats reactivo a filtro: compute con visible_rows tras filtrar + const char* m_flat[] = {"a","1", "b","2", "a","3", "b","4"}; + State st; + st.raw().filters.push_back({0, Op::Eq, "a"}); + auto vis = compute_visible_rows(m_flat, 4, 2, st); + // valores col 1 filtrados: rows 0,2 -> "1","3" + auto s = compute_column_stats(m_flat, 4, 2, 1, 100000, + vis.data(), (int)vis.size()); + check(s.total == 2, "stats reactivo: total = 2 tras filter"); + check(s.numeric_count == 2, "stats reactivo: numeric_count"); + check(s.min == 1.0, "stats reactivo: min sobre subset filtrado"); + check(s.max == 3.0, "stats reactivo: max sobre subset filtrado"); + check(s.mean == 2.0, "stats reactivo: mean sobre subset filtrado"); + } + { + // Indices vacios = scan completo (n_indices=0 hace fallback) + const char* m[] = {"1","2","3"}; + auto s = compute_column_stats(m, 3, 1, 0, 100000, nullptr, 0); + check(s.total == 3, "stats: indices null -> scan completo"); + } + + // --- Ops nuevas: Contains / NotContains / StartsWith / EndsWith --- + check( compare("hello_world", "world", Op::Contains), "contains hello_world has world"); + check(!compare("hello", "xxx", Op::Contains), "!contains hello/xxx"); + check( compare("hello", "xxx", Op::NotContains), "notcontains hello/xxx"); + check(!compare("hello_world", "world", Op::NotContains), "!notcontains hello_world/world"); + check( compare("hello_world", "hello", Op::StartsWith), "starts hello_world/hello"); + check(!compare("hello_world", "world", Op::StartsWith), "!starts hello_world/world"); + check( compare("hello_world", "world", Op::EndsWith), "ends hello_world/world"); + check(!compare("hello_world", "hello", Op::EndsWith), "!ends hello_world/hello"); + check( compare("a", "", Op::Contains), "contains empty needle = true"); + check(!compare("a", "", Op::NotContains), "notcontains empty needle = false"); + check( compare("anything", "", Op::StartsWith), "starts empty prefix = true"); + check( compare("anything", "", Op::EndsWith), "ends empty suffix = true"); + check(!compare("ab", "abcd", Op::StartsWith), "starts needle longer than hay = false"); + check(!compare("ab", "abcd", Op::EndsWith), "ends needle longer than hay = false"); + check(op_is_string_only(Op::Contains) && op_is_string_only(Op::NotContains), + "op_is_string_only contains/notcontains"); + check(op_is_string_only(Op::StartsWith) && op_is_string_only(Op::EndsWith), + "op_is_string_only starts/ends"); + check(!op_is_string_only(Op::Eq) && !op_is_string_only(Op::Gt), + "op_is_string_only false para = y >"); + + // --- Filtros nuevos integrados con compute_visible_rows --- + { + const char* m[] = { + "fn_alpha", "go", + "fn_beta", "py", + "fn_gamma", "go", + "lib_x", "cpp", + }; + State st; st.raw().filters.push_back({0, Op::StartsWith, "fn_"}); + auto r = compute_visible_rows(m, 4, 2, st); + check(r.size() == 3, "filter starts_with fn_"); + st.raw().filters.clear(); + st.raw().filters.push_back({0, Op::EndsWith, "alpha"}); + r = compute_visible_rows(m, 4, 2, st); + check(r.size() == 1 && r[0] == 0, "filter ends_with alpha"); + st.raw().filters.clear(); + st.raw().filters.push_back({0, Op::Contains, "lib"}); + r = compute_visible_rows(m, 4, 2, st); + check(r.size() == 1 && r[0] == 3, "filter contains lib"); + st.raw().filters.clear(); + st.raw().filters.push_back({1, Op::NotContains, "p"}); + r = compute_visible_rows(m, 4, 2, st); + // p contiene a "py" y "cpp"; quedan rows con lang="go" (0, 2) + check(r.size() == 2 && r[0] == 0 && r[1] == 2, "filter notcontains p"); + } + + // --- Range filter como 2 filtros encadenados --- + { + const char* m[] = {"1","2","3","4","5","6","7","8","9","10"}; + State st; + st.raw().filters.push_back({0, Op::Gte, "3"}); + st.raw().filters.push_back({0, Op::Lte, "7"}); + auto r = compute_visible_rows(m, 10, 1, st); + check(r.size() == 5 && r[0] == 2 && r[4] == 6, "range [3..7] AND chain"); + } + + // --- top_categories --- + { + const char* m[] = {"go","py","go","cpp","go","py","cpp","cpp","go"}; + auto s = compute_column_stats(m, 9, 1, 0); + check(s.top_categories.size() == 3, "top_categories size = 3 distintos"); + // go=4, cpp=3, py=2 + check(s.top_categories[0].first == "go" && s.top_categories[0].second == 4, + "top_categories[0] = go,4"); + check(s.top_categories[1].first == "cpp" && s.top_categories[1].second == 3, + "top_categories[1] = cpp,3"); + check(s.top_categories[2].first == "py" && s.top_categories[2].second == 2, + "top_categories[2] = py,2"); + } + + // --- csv_escape --- + check(csv_escape("simple") == "simple", "csv_escape: sin caracteres especiales"); + check(csv_escape("a,b") == "\"a,b\"", "csv_escape: coma -> quotes"); + check(csv_escape("a\"b") == "\"a\"\"b\"", "csv_escape: quote doblada"); + check(csv_escape("a\nb") == "\"a\nb\"", "csv_escape: newline -> quotes"); + check(csv_escape(nullptr) == "", "csv_escape: null -> empty"); + + // --- build_tsv: rect selection con headers --- + { + const char* cells_t[] = { + "1","a","X", + "2","b","Y", + "3","c","Z", + }; + const char* headers_t[] = {"num","letter","tag"}; + std::vector col_order = {0, 1, 2}; + std::vector col_vis = {true, true, true}; + std::vector visible = {0, 1, 2}; + // Selecciona rect (rows 0..1, cols 1..2) -> letter+tag, rows a,X / b,Y + auto tsv = build_tsv(cells_t, 3, 3, headers_t, col_order, col_vis, visible, + 0, 1, 1, 2); + std::string expected = "letter\ttag\na\tX\nb\tY\n"; + check(tsv == expected, "build_tsv rect 0..1 x 1..2 + headers"); + } + { + // build_tsv con columna oculta dentro del rect -> se omite + const char* cells_t[] = {"1","a","X","2","b","Y"}; + const char* headers_t[] = {"num","letter","tag"}; + std::vector col_order = {0, 1, 2}; + std::vector col_vis = {true, false, true}; // letter oculto + std::vector visible = {0, 1}; + auto tsv = build_tsv(cells_t, 2, 3, headers_t, col_order, col_vis, visible, + 0, 1, 0, 2); + std::string expected = "num\ttag\n1\tX\n2\tY\n"; + check(tsv == expected, "build_tsv salta columna oculta"); + } + { + // build_tsv respeta col_order custom + const char* cells_t[] = {"1","a","2","b"}; + const char* headers_t[] = {"num","letter"}; + std::vector col_order = {1, 0}; // letter primero + std::vector col_vis = {true, true}; + std::vector visible = {0, 1}; + auto tsv = build_tsv(cells_t, 2, 2, headers_t, col_order, col_vis, visible, + 0, 1, 0, 1); + std::string expected = "letter\tnum\na\t1\nb\t2\n"; + check(tsv == expected, "build_tsv respeta col_order reordenado"); + } + + // --- build_csv: full filtered view con escape --- + { + const char* cells_c[] = { + "x", "1", + "y,z", "2", + "w\"q","3", + }; + const char* headers_c[] = {"name","n"}; + std::vector col_order = {0, 1}; + std::vector col_vis = {true, true}; + std::vector visible = {0, 1, 2}; + auto csv = build_csv(cells_c, 3, 2, headers_c, col_order, col_vis, visible); + std::string expected = "name,n\nx,1\n\"y,z\",2\n\"w\"\"q\",3\n"; + check(csv == expected, "build_csv con escape de coma y quote"); + } + { + // build_csv vacio si no hay rows visibles + const char* cells_c[] = {"x","1"}; + const char* headers_c[] = {"name","n"}; + std::vector col_order = {0, 1}; + std::vector col_vis = {true, true}; + std::vector visible; // ninguna fila visible + auto csv = build_csv(cells_c, 1, 2, headers_c, col_order, col_vis, visible); + check(csv == "name,n\n", "build_csv solo headers si filter vacia rows"); + } + + // --- ColumnType: auto_detect_type --- + { + const char* m[] = {"1","2","3","4"}; + check(auto_detect_type(m, 4, 1, 0) == ColumnType::Int, "detect Int puro"); + } + { + const char* m[] = {"1","2.5","3"}; + check(auto_detect_type(m, 3, 1, 0) == ColumnType::Float, "detect Float (mix int+float)"); + } + { + const char* m[] = {"true","false","true"}; + check(auto_detect_type(m, 3, 1, 0) == ColumnType::Bool, "detect Bool"); + } + { + const char* m[] = {"2025-01-15","2025-06-30","2024-12-31"}; + check(auto_detect_type(m, 3, 1, 0) == ColumnType::Date, "detect Date ISO"); + } + { + const char* m[] = {"{\"k\":1}","[1,2,3]","{}"}; + check(auto_detect_type(m, 3, 1, 0) == ColumnType::Json, "detect Json"); + } + { + const char* m[] = {"hello","world","foo"}; + check(auto_detect_type(m, 3, 1, 0) == ColumnType::String, "detect String"); + } + { + const char* m[] = {"1","hello","2"}; + check(auto_detect_type(m, 3, 1, 0) == ColumnType::String, "mix int+string -> String"); + } + { + const char* m[] = {"true","yes","false"}; // 'yes' no es bool literal estricto + check(auto_detect_type(m, 3, 1, 0) == ColumnType::String, "bool laxo -> String"); + } + { + const char* m[] = {"","",""}; // todo vacio + check(auto_detect_type(m, 3, 1, 0) == ColumnType::String, "todo vacio -> String"); + } + + // --- ops_for_type --- + { + auto o = ops_for_type(ColumnType::Int); + check(o.size() == 6, "ops Int = 6"); + bool has_gt = false; for (Op x : o) if (x == Op::Gt) has_gt = true; + check(has_gt, "ops Int incluye >"); + } + { + auto o = ops_for_type(ColumnType::Float); + check(o.size() == 6, "ops Float = 6"); + } + { + auto o = ops_for_type(ColumnType::Date); + check(o.size() == 6, "ops Date = 6 (lexical = cronologico)"); + } + { + auto o = ops_for_type(ColumnType::Bool); + check(o.size() == 2, "ops Bool = 2 (= y !=)"); + check(o[0] == Op::Eq && o[1] == Op::Neq, "ops Bool [Eq, Neq]"); + } + { + auto o = ops_for_type(ColumnType::Json); + check(o.size() == 4, "ops Json = 4"); + bool has_contains = false; for (Op x : o) if (x == Op::Contains) has_contains = true; + check(has_contains, "ops Json incluye contains"); + } + { + auto o = ops_for_type(ColumnType::String); + check(o.size() == 6, "ops String = 6"); + bool has_starts = false; for (Op x : o) if (x == Op::StartsWith) has_starts = true; + check(has_starts, "ops String incluye starts"); + } + + // --- effective_type --- + { + const char* m[] = {"1","2","3"}; + check(effective_type(ColumnType::Bool, m, 3, 1, 0) == ColumnType::Bool, + "effective: declared Bool gana sobre datos numericos"); + check(effective_type(ColumnType::Auto, m, 3, 1, 0) == ColumnType::Int, + "effective: Auto resuelve a Int via auto_detect"); + } + + // --- lua_engine: compile + eval + sandbox --- + { + auto* eng = lua_engine::get(); + const char* cells_lua[] = { + "alpha", "10", + "beta", "20", + "gamma", "30", + }; + std::vector hn = {"name", "qty"}; + std::unordered_map n2c = {{"name", 0}, {"qty", 1}}; + auto mk_ctx = [&](int r){ + lua_engine::RowCtx ctx; + ctx.cells = cells_lua; + ctx.orig_cols = 2; + ctx.row = r; + ctx.header_names = &hn; + ctx.name_to_col = &n2c; + return ctx; + }; + + std::string err; + int id = lua_engine::compile(eng, "return row.qty * 2", &err); + check(id >= 0, "lua: compile arithmetic OK"); + check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "20", "lua: eval 10*2 = 20"); + check(lua_engine::eval(eng, id, mk_ctx(2), &err) == "60", "lua: eval 30*2 = 60"); + lua_engine::release(eng, id); + + id = lua_engine::compile(eng, "return fn.upper(row.name)", &err); + check(id >= 0, "lua: compile builtin OK"); + check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "ALPHA", "lua: fn.upper"); + lua_engine::release(eng, id); + + id = lua_engine::compile(eng, + "if tonumber(row.qty) >= 20 then return 'high' else return 'low' end", &err); + check(id >= 0, "lua: compile if/else OK"); + check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "low", "lua: if/else low"); + check(lua_engine::eval(eng, id, mk_ctx(1), &err) == "high", "lua: if/else high"); + lua_engine::release(eng, id); + + id = lua_engine::compile(eng, "return io == nil", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", + "lua sandbox: io is nil"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return require == nil", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", + "lua sandbox: require is nil"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return dofile == nil", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", + "lua sandbox: dofile is nil"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return os.execute == nil", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", + "lua sandbox: os.execute is nil"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return type(os.date) == 'function'", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", + "lua sandbox: os.date preservado"); + lua_engine::release(eng, id); + + err.clear(); + id = lua_engine::compile(eng, "return row.qty *", &err); + check(id == -1 && !err.empty(), "lua: error sintaxis devuelve -1 + err"); + + id = lua_engine::compile(eng, "error('boom')", &err); + check(id >= 0, "lua: compile error() OK"); + err.clear(); + std::string out = lua_engine::eval(eng, id, mk_ctx(0), &err); + check(out == "" && !err.empty(), "lua: runtime error -> '' + err"); + lua_engine::release(eng, id); + + id = lua_engine::compile(eng, "return fn.length('hello')", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "5", "lua: fn.length"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return fn.concat('a', '-', 'b')", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "a-b", "lua: fn.concat"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return fn.contains('foobar', 'oob')", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", "lua: fn.contains"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return fn.starts_with('hello_world', 'hello')", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", "lua: fn.starts_with"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return fn.year('2025-09-10')", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "2025", "lua: fn.year"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return fn.month('2025-09-10')", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "9", "lua: fn.month"); + lua_engine::release(eng, id); + + id = lua_engine::compile(eng, "return row[2]", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "10", "lua: row[2] = qty"); + lua_engine::release(eng, id); + id = lua_engine::compile(eng, "return row.nope == nil", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", + "lua: col inexistente -> nil"); + lua_engine::release(eng, id); + } + + // --- lua_engine v2: [col] preprocesser, type-aware push, recursion --- + { + auto* eng = lua_engine::get(); + // Dataset con tipos declarados + const char* cells2[] = { + "alpha", "10", "1.5", "true", "2025-01-15", + "beta", "20", "2.5", "false", "2024-06-01", + "gamma", "30", "3.5", "true", "2026-12-31", + }; + std::vector hn2 = {"name", "qty", "size", "flag", "dt"}; + std::unordered_map n2c2 = { + {"name", 0}, {"qty", 1}, {"size", 2}, {"flag", 3}, {"dt", 4} + }; + ColumnType types2[] = { + ColumnType::String, ColumnType::Int, ColumnType::Float, + ColumnType::Bool, ColumnType::Date + }; + std::vector derived; + std::unordered_map dn2i; + auto mk_ctx = [&](int r){ + lua_engine::RowCtx ctx; + ctx.cells = cells2; + ctx.orig_cols = 5; + ctx.row = r; + ctx.header_names = &hn2; + ctx.name_to_col = &n2c2; + ctx.types_orig = types2; + ctx.n_types_orig = 5; + ctx.derived = &derived; + ctx.derived_name_to_idx = &dn2i; + return ctx; + }; + + std::string err; + // [col] sintaxis basica + int id = lua_engine::compile(eng, "return [qty] + 1", &err); + check(id >= 0, "lua v2: compile [qty] + 1"); + check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "11", "lua v2: [qty]+1 row0 = 11"); + lua_engine::release(eng, id); + + // Auto-return: expresion suelta sin return + id = lua_engine::compile(eng, "[qty] + [size]", &err); + check(id >= 0, "lua v2: auto-return compile"); + // Int 10 + Float 1.5 -> 11.5 + check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "11.5", + "lua v2: auto-return [qty]+[size] = 11.5"); + lua_engine::release(eng, id); + + // Type-aware push: Int * 2 = integer + id = lua_engine::compile(eng, "[qty] * 2", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(1), &err) == "40", + "lua v2: Int*2 = integer (40 no 40.0)"); + lua_engine::release(eng, id); + + // Bool push: if [flag] then ... + id = lua_engine::compile(eng, "if [flag] then return 'yes' else return 'no' end", &err); + check(id >= 0, "lua v2: bool if compile"); + check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "yes", "lua v2: flag=true -> yes"); + check(lua_engine::eval(eng, id, mk_ctx(1), &err) == "no", "lua v2: flag=false -> no"); + lua_engine::release(eng, id); + + // Date push: string + id = lua_engine::compile(eng, "fn.year([dt])", &err); + check(id >= 0, "lua v2: fn.year([dt]) compile"); + check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "2025", "lua v2: year row0 = 2025"); + check(lua_engine::eval(eng, id, mk_ctx(2), &err) == "2026", "lua v2: year row2 = 2026"); + lua_engine::release(eng, id); + + // String concat + id = lua_engine::compile(eng, "[name] .. '-' .. [qty]", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "alpha-10", + "lua v2: string concat [name].'-'.[qty]"); + lua_engine::release(eng, id); + + // [col] dentro de string literal: NO se traduce + id = lua_engine::compile(eng, "return '[qty]'", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "[qty]", + "lua v2: string literal preserva [qty]"); + lua_engine::release(eng, id); + + // [col] dentro de comentario corto: NO se traduce + id = lua_engine::compile(eng, "-- [qty] is ignored\nreturn [qty]", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "10", + "lua v2: short comment preserva [qty]"); + lua_engine::release(eng, id); + + // [col] dentro de comentario largo: NO se traduce + id = lua_engine::compile(eng, "--[[ [qty] is here ]]\nreturn [qty]", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "10", + "lua v2: long comment preserva [qty]"); + lua_engine::release(eng, id); + + // t[1] indice numerico: NO se traduce + id = lua_engine::compile(eng, "local t = {7,8,9}\nreturn t[1]", &err); + check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "7", + "lua v2: indice numerico t[1] intacto"); + lua_engine::release(eng, id); + + // UTF-8 en nombre de col + std::vector hn_utf = {"año", "qty"}; + std::unordered_map n2c_utf = {{"año", 0}, {"qty", 1}}; + const char* cells_utf[] = {"2025", "10", "2026", "20"}; + ColumnType types_utf[] = {ColumnType::Int, ColumnType::Int}; + std::vector empty_d; + std::unordered_map empty_dn; + auto mk_utf = [&](int r){ + lua_engine::RowCtx c; + c.cells = cells_utf; c.orig_cols = 2; c.row = r; + c.header_names = &hn_utf; c.name_to_col = &n2c_utf; + c.types_orig = types_utf; c.n_types_orig = 2; + c.derived = &empty_d; c.derived_name_to_idx = &empty_dn; + return c; + }; + id = lua_engine::compile(eng, "[año] + 1", &err); + check(id >= 0, "lua v2: compile [año] UTF-8"); + check(lua_engine::eval(eng, id, mk_utf(0), &err) == "2026", + "lua v2: [año] UTF-8 row0 = 2026"); + lua_engine::release(eng, id); + + // Recursivo: derived A refs orig, derived B refs A + // A = "[qty] * 2" (Int) + // B = "[A] + 100" (Int) + int idA = lua_engine::compile(eng, "[qty] * 2", &err); + check(idA >= 0, "lua v2: compile derived A"); + DerivedColumn dA; dA.source_col = -1; dA.type = ColumnType::Int; + dA.name = "A"; dA.formula = "[qty] * 2"; dA.lua_id = idA; + derived.push_back(dA); + dn2i["A"] = 0; + + int idB = lua_engine::compile(eng, "[A] + 100", &err); + check(idB >= 0, "lua v2: compile derived B (refs A)"); + DerivedColumn dB; dB.source_col = -1; dB.type = ColumnType::Int; + dB.name = "B"; dB.formula = "[A] + 100"; dB.lua_id = idB; + derived.push_back(dB); + dn2i["B"] = 1; + + // row0: qty=10, A=10*2=20, B=20+100=120 + check(lua_engine::eval(eng, idA, mk_ctx(0), &err) == "20", + "lua v2: derived A = 20"); + check(lua_engine::eval(eng, idB, mk_ctx(0), &err) == "120", + "lua v2: derived B = A + 100 = 120 (recursive)"); + + // Cadena de 3 niveles: C = [B] * 2 + int idC = lua_engine::compile(eng, "[B] * 2", &err); + check(idC >= 0, "lua v2: compile derived C (refs B)"); + DerivedColumn dC; dC.source_col = -1; dC.type = ColumnType::Int; + dC.name = "C"; dC.formula = "[B] * 2"; dC.lua_id = idC; + derived.push_back(dC); + dn2i["C"] = 2; + check(lua_engine::eval(eng, idC, mk_ctx(0), &err) == "240", + "lua v2: chain C -> B -> A -> qty = 240"); + + // Ciclo: D refs E, E refs D -> nil propaga + int idD = lua_engine::compile(eng, "[E] + 1", &err); + check(idD >= 0, "lua v2: compile D (refs E)"); + DerivedColumn dD; dD.source_col=-1; dD.type=ColumnType::Int; + dD.name="D"; dD.formula="[E]+1"; dD.lua_id=idD; + derived.push_back(dD); dn2i["D"] = 3; + + int idE = lua_engine::compile(eng, "[D] + 1", &err); + check(idE >= 0, "lua v2: compile E (refs D)"); + DerivedColumn dE; dE.source_col=-1; dE.type=ColumnType::Int; + dE.name="E"; dE.formula="[D]+1"; dE.lua_id=idE; + derived.push_back(dE); dn2i["E"] = 4; + + // Evaluar D debe romper el ciclo: [E] devuelve nil, nil+1 error, + // pcall captura -> eval devuelve "" + err + err.clear(); + std::string r = lua_engine::eval(eng, idD, mk_ctx(0), &err); + check(r.empty(), "lua v2: ciclo D<->E devuelve vacio sin crash"); + + lua_engine::release(eng, idA); + lua_engine::release(eng, idB); + lua_engine::release(eng, idC); + lua_engine::release(eng, idD); + lua_engine::release(eng, idE); + derived.clear(); + dn2i.clear(); + + // Retipo puro (sin formula) accesible via row. + derived.push_back({0, ColumnType::String, "name_str", "", -1, ""}); // source_col=0 (name) + dn2i["name_str"] = 0; + int idF = lua_engine::compile(eng, "[name_str] .. '_X'", &err); + check(idF >= 0, "lua v2: compile usando retipo puro"); + check(lua_engine::eval(eng, idF, mk_ctx(0), &err) == "alpha_X", + "lua v2: row[retipo_puro] funciona"); + lua_engine::release(eng, idF); + } + + // --- autocomplete helpers: find_open_bracket + insert_column_ref --- + { + std::string ft; + // Cursor justo despues de "[" + int idx = find_open_bracket("foo [", 5, 5, ft); + check(idx == 4 && ft == "", "ac: find_open_bracket cursor tras ["); + idx = find_open_bracket("foo [abc", 8, 8, ft); + check(idx == 4 && ft == "abc", "ac: filter 'abc' tras ["); + idx = find_open_bracket("foo [a] + 1", 11, 11, ft); + check(idx == -1, "ac: bracket cerrado -> -1"); + idx = find_open_bracket("foo [a\nbar", 10, 10, ft); + check(idx == -1, "ac: newline interrumpe"); + idx = find_open_bracket("nada", 4, 4, ft); + check(idx == -1, "ac: sin bracket -> -1"); + idx = find_open_bracket("[xy", 3, 3, ft); + check(idx == 0 && ft == "xy", "ac: bracket al inicio"); + idx = find_open_bracket("a [b] + [c", 10, 10, ft); + check(idx == 8 && ft == "c", "ac: segundo bracket abierto"); + } + { + int nc = 0; + std::string r = insert_column_ref("foo [", 4, 5, "size_kb", nc); + check(r == "foo [size_kb]" && nc == 13, "ac: insert tras [ -> [size_kb]"); + r = insert_column_ref("foo [ab", 4, 7, "size_kb", nc); + check(r == "foo [size_kb]" && nc == 13, "ac: reemplaza filter tecleado"); + r = insert_column_ref("[a] + [", 6, 7, "qty", nc); + check(r == "[a] + [qty]" && nc == 11, "ac: insert preserva prefijo"); + r = insert_column_ref("[a", 0, 2, "name", nc); + check(r == "[name]" && nc == 6, "ac: reemplaza [a -> [name]"); + // Edge: start fuera de rango + r = insert_column_ref("hi", -1, 1, "n", nc); + check(r == "hi", "ac: start invalido = no-op"); + r = insert_column_ref("hi", 0, 99, "n", nc); + check(r == "hi", "ac: cursor invalido = no-op"); + } + + // --- preprocess() expuesto: brackets + auto-return --- + { + check(lua_engine::preprocess("[a] + [b]") == "return row[\"a\"] + row[\"b\"]", + "preprocess: [a]+[b] -> return row[\"a\"] + row[\"b\"]"); + check(lua_engine::preprocess("return [a]") == "return row[\"a\"]", + "preprocess: con return explicito no duplica"); + check(lua_engine::preprocess("if [a] then return 1 end") + == "if row[\"a\"] then return 1 end", + "preprocess: if no añade return"); + check(lua_engine::preprocess("'[a]'") == "return '[a]'", + "preprocess: string literal preserva [a]"); + check(lua_engine::preprocess("-- [a]\nreturn 1") + == "-- [a]\nreturn 1", + "preprocess: short comment preserva [a]"); + check(lua_engine::preprocess("[a b]") == "return row[\"a b\"]", + "preprocess: nombre con espacio"); + } + + // --- TQL: aggregation_alias + aggregation_type --- + { + check(aggregation_alias({AggFn::Count}) == "count", "tql alias count"); + check(aggregation_alias({AggFn::Avg, "size_kb"}) == "avg_size_kb", "tql alias avg_size_kb"); + check(aggregation_alias({AggFn::Distinct, "name"}) == "distinct_name", "tql alias distinct_name"); + Aggregation p95; p95.fn = AggFn::Percentile; p95.col = "size_kb"; p95.arg = 0.95; + check(aggregation_alias(p95) == "p95_size_kb", "tql alias p95_size_kb"); + Aggregation aliased; aliased.fn = AggFn::Sum; aliased.col = "x"; aliased.alias = "total"; + check(aggregation_alias(aliased) == "total", "tql alias usa alias explicito"); + + std::vector hdrs = {"lang", "size_kb", "name"}; + std::vector tps = {ColumnType::String, ColumnType::Float, ColumnType::String}; + check(aggregation_type({AggFn::Count}, hdrs, tps) == ColumnType::Int, "tql type count = Int"); + check(aggregation_type({AggFn::Distinct, "name"}, hdrs, tps) == ColumnType::Int, "tql type distinct = Int"); + check(aggregation_type({AggFn::Avg, "size_kb"}, hdrs, tps) == ColumnType::Float, "tql type avg = Float"); + check(aggregation_type({AggFn::Min, "name"}, hdrs, tps) == ColumnType::String, "tql type min(string) = String"); + check(aggregation_type({AggFn::Min, "size_kb"}, hdrs, tps) == ColumnType::Float, "tql type min(float) = Float"); + } + + // --- TQL: compute_stage passthrough (filter + sort sin group) --- + { + const char* cells_t[] = { + "go", "10", + "py", "20", + "go", "30", + "cpp", "5", + }; + std::vector hdrs = {"lang", "n"}; + std::vector tps = {ColumnType::String, ColumnType::Int}; + Stage s; + s.filters.push_back({0, Op::Eq, "go"}); + s.sorts.push_back({"n", true}); + auto out = compute_stage(cells_t, 4, 2, hdrs, tps, s); + check(out.rows == 2 && out.cols == 2, "tql passthrough rows + cols"); + check(std::string(out.cells[0]) == "go" && std::string(out.cells[1]) == "30", + "tql passthrough sort desc por n: 30 primero"); + check(std::string(out.cells[2]) == "go" && std::string(out.cells[3]) == "10", + "tql passthrough sort desc: 10 segundo"); + } + + // --- TQL: compute_stage group by 1 col + count --- + { + const char* cells_t[] = { + "go", "10", + "py", "20", + "go", "30", + "cpp", "5", + "go", "15", + "py", "25", + }; + std::vector hdrs = {"lang", "n"}; + std::vector tps = {ColumnType::String, ColumnType::Int}; + Stage s; + s.breakouts.push_back("lang"); + s.aggregations.push_back({AggFn::Count}); + s.aggregations.push_back({AggFn::Avg, "n"}); + s.aggregations.push_back({AggFn::Sum, "n"}); + s.sorts.push_back({"count", true}); + auto out = compute_stage(cells_t, 6, 2, hdrs, tps, s); + check(out.cols == 4, "tql group: cols = breakouts + aggs"); + check(out.rows == 3, "tql group: 3 grupos (go/py/cpp)"); + // headers + check(out.headers[0] == "lang" && out.headers[1] == "count" && + out.headers[2] == "avg_n" && out.headers[3] == "sum_n", + "tql group: headers correctos"); + // sort desc por count -> go (3) primero, py (2) segundo, cpp (1) ultimo + check(std::string(out.cells[0*4+0]) == "go" && + std::string(out.cells[0*4+1]) == "3", + "tql group row0: lang=go count=3"); + check(std::string(out.cells[1*4+0]) == "py" && + std::string(out.cells[1*4+1]) == "2", + "tql group row1: lang=py count=2"); + // avg de go: (10+30+15)/3 = 18.33 (formatear como %.4g = "18.33") + // sum de go: 55 + check(std::string(out.cells[0*4+2]).find("18.33") != std::string::npos, + "tql group: avg_n go ~ 18.33"); + check(std::string(out.cells[0*4+3]) == "55", "tql group: sum_n go = 55"); + } + + // --- TQL: compute_stage 2 breakouts + multiple aggs --- + { + const char* cells_t[] = { + "go", "core", "10", + "go", "infra", "20", + "py", "core", "30", + "go", "core", "40", + "py", "infra", "50", + "py", "core", "60", + }; + std::vector hdrs = {"lang", "domain", "n"}; + std::vector tps = {ColumnType::String, ColumnType::String, ColumnType::Int}; + Stage s; + s.breakouts.push_back("lang"); + s.breakouts.push_back("domain"); + s.aggregations.push_back({AggFn::Count}); + s.aggregations.push_back({AggFn::Min, "n"}); + s.aggregations.push_back({AggFn::Max, "n"}); + auto out = compute_stage(cells_t, 6, 3, hdrs, tps, s); + check(out.rows == 4, "tql 2 breakouts: 4 grupos (go/core, go/infra, py/core, py/infra)"); + check(out.cols == 5, "tql 2 breakouts: 5 cols"); + } + + // --- TQL: percentile + median + stddev --- + { + const char* cells_t[] = { + "a", "1", + "a", "2", + "a", "3", + "a", "4", + "a", "5", + "a", "6", + "a", "7", + "a", "8", + "a", "9", + }; + std::vector hdrs = {"k", "n"}; + std::vector tps = {ColumnType::String, ColumnType::Int}; + Stage s; + s.breakouts.push_back("k"); + s.aggregations.push_back({AggFn::Median, "n"}); + s.aggregations.push_back({AggFn::P25, "n"}); + s.aggregations.push_back({AggFn::P75, "n"}); + Aggregation p90; p90.fn = AggFn::P90; p90.col = "n"; + s.aggregations.push_back(p90); + Aggregation pct; pct.fn = AggFn::Percentile; pct.col = "n"; pct.arg = 0.5; + s.aggregations.push_back(pct); + s.aggregations.push_back({AggFn::Stddev, "n"}); + auto out = compute_stage(cells_t, 9, 2, hdrs, tps, s); + check(out.rows == 1, "tql percentiles: 1 grupo"); + // headers: k, median_n, p25_n, p75_n, p90_n, p50_n, stddev_n + check(out.headers[1] == "median_n", "tql median alias"); + check(out.headers[2] == "p25_n", "tql p25 alias"); + check(out.headers[4] == "p90_n", "tql p90 alias"); + check(out.headers[5] == "p50_n", "tql percentile generico -> p50_n"); + check(out.headers[6] == "stddev_n", "tql stddev alias"); + // median = 5 + check(std::string(out.cells[1]) == "5", "tql median(1..9) = 5"); + // p25 = 3, p75 = 7 + check(std::string(out.cells[2]) == "3", "tql p25(1..9) = 3"); + check(std::string(out.cells[3]) == "7", "tql p75(1..9) = 7"); + } + + // --- TQL: distinct counts --- + { + const char* cells_t[] = { + "go", "filter", + "go", "map", + "go", "filter", + "py", "sma", + "py", "sma", + "py", "ema", + }; + std::vector hdrs = {"lang", "name"}; + std::vector tps = {ColumnType::String, ColumnType::String}; + Stage s; + s.breakouts.push_back("lang"); + s.aggregations.push_back({AggFn::Distinct, "name"}); + auto out = compute_stage(cells_t, 6, 2, hdrs, tps, s); + check(out.rows == 2, "tql distinct: 2 grupos"); + // go: distinct {filter, map} = 2 + // py: distinct {sma, ema} = 2 + for (int r = 0; r < 2; ++r) { + check(std::string(out.cells[r * 2 + 1]) == "2", + "tql distinct cuenta unicos"); + } + } + + // --- TQL: stage chain (output of stage 0 feeds stage 1) --- + { + // Stage 0: filter lang=go -> passthrough. + // Stage 1: group by domain, count + avg n. + const char* cells_t[] = { + "go", "core", "10", + "go", "infra", "20", + "py", "core", "30", + "go", "core", "40", + }; + std::vector hdrs = {"lang", "domain", "n"}; + std::vector tps = {ColumnType::String, ColumnType::String, ColumnType::Int}; + Stage s0; + s0.filters.push_back({0, Op::Eq, "go"}); + auto out0 = compute_stage(cells_t, 4, 3, hdrs, tps, s0); + check(out0.rows == 3, "tql chain stage0: filtra a 3 filas"); + + Stage s1; + s1.breakouts.push_back("domain"); + s1.aggregations.push_back({AggFn::Count}); + s1.aggregations.push_back({AggFn::Avg, "n"}); + auto out1 = compute_stage(out0.cells.data(), out0.rows, out0.cols, + out0.headers, out0.types, s1); + check(out1.rows == 2, "tql chain stage1: 2 grupos (core/infra)"); + check(out1.headers[0] == "domain" && out1.headers[1] == "count" && + out1.headers[2] == "avg_n", + "tql chain stage1: headers"); + } + + // --- TQL emit --- + { + State st; + std::vector hdrs = {"lang", "n", "name"}; + // Empty state -> minimal + std::vector tps = {ColumnType::String, ColumnType::Int, ColumnType::String}; + std::string out = tql::emit(st, hdrs, tps); + check(out.find("stages") != std::string::npos, "tql emit: contiene stages"); + + // Con filters + sort + st.raw().filters.push_back({0, Op::Eq, "go"}); + st.raw().filters.push_back({1, Op::Gte, "10"}); + set_sort_idx(st, 1, false); + set_sort_desc(st, true); + out = tql::emit(st, hdrs, tps); + check(out.find("filter") != std::string::npos, "tql emit: incluye filter"); + check(out.find("\"=\"") != std::string::npos, "tql emit: op ="); + check(out.find("\"lang\"") != std::string::npos, "tql emit: col lang"); + check(out.find("\"go\"") != std::string::npos, "tql emit: value go"); + check(out.find("\">=\"") != std::string::npos, "tql emit: op >="); + check(out.find("sort") != std::string::npos, "tql emit: incluye sort"); + check(out.find("\"desc\"") != std::string::npos, "tql emit: sort dir desc"); + } + + // --- TQL apply --- + { + State st; + std::vector hdrs = {"lang", "n", "name"}; + const char* cells_t[] = { + "go", "10", "filter", + "py", "20", "sma", + "go", "30", "map", + }; + std::string text = R"LUA( +return { + stages = { + { + filter = { + {"=", "lang", "go"}, + {">=", "n", "10"}, + }, + sort = { {"desc", "n"} }, + } + } +})LUA"; + std::string err; + bool ok = tql::apply(text, st, hdrs, std::vector{}, cells_t, 3, 3, &err); + check(ok, "tql apply: parsea OK"); + check(st.raw().filters.size() == 2, "tql apply: 2 filters"); + check(st.raw().filters[0].col == 0 && st.raw().filters[0].op == Op::Eq && + st.raw().filters[0].value == "go", "tql apply: filter 0 = lang=go"); + check(st.raw().filters[1].col == 1 && st.raw().filters[1].op == Op::Gte && + st.raw().filters[1].value == "10", "tql apply: filter 1 = n>=10"); + // sort se almacena por nombre en el nuevo modelo (no por indice). + check(st.raw().sorts.size() == 1 && st.raw().sorts[0].col == "n" && + st.raw().sorts[0].desc == true, + "tql apply: sort desc por n (by name)"); + } + + // --- TQL apply error: invalid Lua --- + { + State st; + std::vector hdrs = {"a"}; + std::string err; + bool ok = tql::apply("return {{{ not valid lua", st, hdrs, std::vector{}, nullptr, 0, 1, &err); + check(!ok && !err.empty(), "tql apply: lua invalido -> false + err"); + } + + // --- TQL apply error: root no es tabla --- + { + State st; + std::vector hdrs = {"a"}; + std::string err; + bool ok = tql::apply("return 42", st, hdrs, std::vector{}, nullptr, 0, 1, &err); + check(!ok && err.find("table") != std::string::npos, + "tql apply: root no-tabla -> error"); + } + + // --- TQL round-trip: emit -> apply -> compare --- + { + State st0; + std::vector hdrs = {"lang", "n"}; + st0.raw().filters.push_back({0, Op::Contains, "g"}); + st0.raw().filters.push_back({1, Op::Lt, "100"}); + set_sort_idx(st0, 0, false); + set_sort_desc(st0, false); + + std::vector tps_rt = {ColumnType::String, ColumnType::Int}; + std::string text = tql::emit(st0, hdrs, tps_rt); + + State st1; + const char* cells_t[] = {"go","1","py","2"}; + std::string err; + bool ok = tql::apply(text, st1, hdrs, tps_rt, cells_t, 2, 2, &err); + check(ok, "tql round-trip: apply OK"); + check(st1.raw().filters.size() == 2, "tql round-trip: 2 filters preservados"); + check(st1.raw().filters[0].col == 0 && st1.raw().filters[0].op == Op::Contains && + st1.raw().filters[0].value == "g", + "tql round-trip: contains preservado"); + check(st1.raw().filters[1].op == Op::Lt && st1.raw().filters[1].value == "100", + "tql round-trip: < preservado"); + // En el round-trip el sort se preserva por nombre. El helper + // set_sort_idx emite con sintaxis "@N" que el round-trip respeta. + check(st1.raw().sorts.size() == 1 && st1.raw().sorts[0].desc == false, + "tql round-trip: sort asc preservado"); + } + + // --- TQL apply: expressions compila + auto-detect tipo --- + { + State st; + std::vector hdrs = {"size_kb", "name"}; + const char* cells_t[] = { + "1.5", "alpha", + "2.0", "beta", + "3.5", "gamma", + }; + std::string text = R"LUA( +return { + stages = { + { + expressions = { + size_bytes = "[size_kb] * 1024", + double_size = "[size_kb] * 2", + }, + } + } +})LUA"; + std::string err; + bool ok = tql::apply(text, st, hdrs, std::vector{}, cells_t, 3, 2, &err); + check(ok, "tql apply expressions: OK"); + check(st.raw().derived.size() == 2, "tql apply: 2 derived cols"); + // Verifica que tienen lua_id valido y formula + for (const auto& d : st.raw().derived) { + check(d.lua_id >= 0 && !d.formula.empty(), + "tql apply: derived compiled OK"); + } + } + + // --- TQL columns + color round-trip --- + { + check(tql::color_to_hex(0xFFFF0000) == "#0000ff", "tql color: blue 0xFFFF0000 -> #0000ff"); + check(tql::color_to_hex(0x80808080) == "#80808080", "tql color: con alpha"); + check(tql::hex_to_color("#0000ff") == 0xFFFF0000, "tql hex: #0000ff -> blue full alpha"); + check(tql::hex_to_color("#80808080") == 0x80808080, "tql hex: roundtrip con alpha"); + check(tql::column_type_from_string("int") == ColumnType::Int, "tql ctype: int"); + check(tql::column_type_from_string("bool") == ColumnType::Bool, "tql ctype: bool"); + check(tql::column_type_from_string("date") == ColumnType::Date, "tql ctype: date"); + check(tql::column_type_from_string("zzz") == ColumnType::Auto, "tql ctype: unknown -> auto"); + } + { + // Emit columns con visibilidad + color rules + State st; + std::vector hdrs = {"lang", "n"}; + std::vector tps = {ColumnType::String, ColumnType::Int}; + st.col_visible = {true, false}; + st.col_order = {1, 0}; + st.color_rules.push_back({0, "go", 0xFF6BB586}); + + std::string out = tql::emit(st, hdrs, tps); + check(out.find("columns") != std::string::npos, "tql emit: include columns"); + check(out.find("visible = false") != std::string::npos, "tql emit: visible=false"); + check(out.find("visible = true") != std::string::npos, "tql emit: visible=true"); + check(out.find("color_rules") != std::string::npos, "tql emit: include color_rules"); + check(out.find("display = \"table\"") != std::string::npos, "tql emit: display table"); + check(out.find("visualization_settings") != std::string::npos, "tql emit: viz settings"); + } + { + // Round-trip columns: emit -> apply -> compare visibility/order/color_rules + State st0; + std::vector hdrs = {"lang", "n"}; + std::vector tps = {ColumnType::String, ColumnType::Int}; + st0.col_visible = {true, false}; + st0.col_order = {1, 0}; + st0.color_rules.push_back({0, "py", 0xFFB5866B}); + + std::string text = tql::emit(st0, hdrs, tps); + + State st1; + const char* cells_t[] = {"go","1","py","2"}; + std::string err; + bool ok = tql::apply(text, st1, hdrs, tps, cells_t, 2, 2, &err); + check(ok, "tql round-trip columns: apply OK"); + check(st1.col_visible.size() == 2 && !st1.col_visible[1], + "tql round-trip: visible[1] = false preservado"); + check(st1.col_order.size() == 2 && st1.col_order[0] == 1 && st1.col_order[1] == 0, + "tql round-trip: col_order [1,0] preservado"); + check(st1.color_rules.size() == 1 && + st1.color_rules[0].col == 0 && + st1.color_rules[0].equals == "py" && + st1.color_rules[0].color == 0xFFB5866B, + "tql round-trip: color_rule preservado"); + } + { + // Apply con expression + columns: type del derived va via columns.type + State st; + std::vector hdrs = {"size_kb"}; + std::vector tps = {ColumnType::Float}; + const char* cells_t[] = {"1.5", "2.0", "3.5"}; + std::string text = R"LUA( +return { + stages = { + { + expressions = { size_bytes = "[size_kb] * 1024" } + } + }, + columns = { + {name = "size_kb", type = "float", visible = true, order = 1}, + {name = "size_bytes", type = "int", visible = true, order = 2, + color_rules = {{equals = "1536", color = "#86b56b"}}}, + } +})LUA"; + std::string err; + bool ok = tql::apply(text, st, hdrs, tps, cells_t, 3, 1, &err); + check(ok, "tql apply: stages + columns combo"); + check(st.raw().derived.size() == 1, "tql apply: derived col size_bytes creada"); + // type override de auto-detect: columns dice "int", aunque auto-detect daria Float + check(st.raw().derived[0].type == ColumnType::Int, + "tql apply: columns.type sobrescribe auto-detect derived"); + // color_rule sobre derived col (idx orig_cols+0 = 1) + check(st.color_rules.size() == 1 && + st.color_rules[0].col == 1 && + st.color_rules[0].equals == "1536", + "tql apply: color_rule sobre derived col"); + // col_order = [size_kb=0, size_bytes=1] + check(st.col_order.size() == 2 && st.col_order[0] == 0 && st.col_order[1] == 1, + "tql apply: col_order desde columns.order"); + } + + // --- lua_string_literal --- + { + check(tql::lua_string_literal("simple") == "\"simple\"", "tql literal: simple"); + check(tql::lua_string_literal("a\"b") == "\"a\\\"b\"", "tql literal: quote escape"); + check(tql::lua_string_literal("a\\b") == "\"a\\\\b\"", "tql literal: backslash escape"); + check(tql::lua_string_literal("a\nb") == "\"a\\nb\"", "tql literal: newline escape"); + } + + // --- Phase 3.1: derived eval sobre stage output --- + { + const char* cells_t[] = { + "go", "core", "10", + "go", "viz", "20", + "py", "core", "30", + "go", "core", "40", + "py", "viz", "50", + "py", "core", "60", + }; + std::vector hdrs = {"lang", "domain", "n"}; + std::vector tps = {ColumnType::String, ColumnType::String, ColumnType::Int}; + Stage s1; + s1.breakouts.push_back("lang"); + s1.aggregations.push_back({AggFn::Count}); + s1.aggregations.push_back({AggFn::Sum, "n"}); + auto out1 = compute_stage(cells_t, 6, 3, hdrs, tps, s1); + + auto* eng = lua_engine::get(); + std::string err; + int id = lua_engine::compile(eng, "[count] * [sum_n]", &err); + check(id >= 0, "phase3.1: compile derived sobre stage output"); + std::vector out_hn = out1.headers; + std::unordered_map n2c; + for (size_t i = 0; i < out_hn.size(); ++i) n2c[out_hn[i]] = (int)i; + std::vector results; + for (int r = 0; r < out1.rows; ++r) { + lua_engine::RowCtx ctx; + ctx.cells = out1.cells.data(); + ctx.orig_cols = out1.cols; + ctx.row = r; + ctx.header_names = &out_hn; + ctx.name_to_col = &n2c; + ctx.types_orig = out1.types.data(); + ctx.n_types_orig = out1.cols; + std::string e; + results.push_back(lua_engine::eval(eng, id, ctx, &e)); + } + int go_idx = -1, py_idx = -1; + for (int r = 0; r < out1.rows; ++r) { + const char* lang = out1.cells[r * out1.cols + 0]; + if (std::strcmp(lang, "go") == 0) go_idx = r; + if (std::strcmp(lang, "py") == 0) py_idx = r; + } + check(go_idx >= 0 && py_idx >= 0, "phase3.1: encontrar grupos go y py"); + check(results[go_idx] == "210", "phase3.1: go count*sum_n = 210"); + check(results[py_idx] == "420", "phase3.1: py count*sum_n = 420"); + lua_engine::release(eng, id); + } + { + // Recursividad: derived B sobre stage output referencia derived A. + const char* cells_t[] = { + "go", "x", + "go", "y", + "py", "z", + }; + std::vector hdrs = {"lang", "name"}; + std::vector tps = {ColumnType::String, ColumnType::String}; + Stage s1; + s1.breakouts.push_back("lang"); + s1.aggregations.push_back({AggFn::Count}); + auto out1 = compute_stage(cells_t, 3, 2, hdrs, tps, s1); + + auto* eng = lua_engine::get(); + std::string err; + int idA = lua_engine::compile(eng, "[count] + 100", &err); + check(idA >= 0, "phase3.1: compile derived A sobre stage output"); + std::vector out_hn = out1.headers; + std::unordered_map n2c; + for (size_t i = 0; i < out_hn.size(); ++i) n2c[out_hn[i]] = (int)i; + std::vector der; + der.push_back({-1, ColumnType::Int, "A", "[count] + 100", idA, ""}); + std::unordered_map dn2i; + dn2i["A"] = 0; + + int idB = lua_engine::compile(eng, "[A] * 2", &err); + check(idB >= 0, "phase3.1: compile derived B refs A"); + + std::vector resB; + for (int r = 0; r < out1.rows; ++r) { + lua_engine::RowCtx ctx; + ctx.cells = out1.cells.data(); + ctx.orig_cols = out1.cols; + ctx.row = r; + ctx.header_names = &out_hn; + ctx.name_to_col = &n2c; + ctx.types_orig = out1.types.data(); + ctx.n_types_orig = out1.cols; + ctx.derived = &der; + ctx.derived_name_to_idx = &dn2i; + std::string e; + resB.push_back(lua_engine::eval(eng, idB, ctx, &e)); + } + int go_idx = -1, py_idx = -1; + for (int r = 0; r < out1.rows; ++r) { + const char* lang = out1.cells[r * out1.cols + 0]; + if (std::strcmp(lang, "go") == 0) go_idx = r; + if (std::strcmp(lang, "py") == 0) py_idx = r; + } + check(resB[go_idx] == "204", "phase3.1: derived B chain (count+100)*2 = 204 go"); + check(resB[py_idx] == "202", "phase3.1: derived B chain (count+100)*2 = 202 py"); + lua_engine::release(eng, idA); + lua_engine::release(eng, idB); + } + + // --- column_type_name + icon no nulos --- + { + const ColumnType all[] = { ColumnType::Auto, ColumnType::String, ColumnType::Int, + ColumnType::Float, ColumnType::Bool, ColumnType::Date, + ColumnType::Json }; + for (auto t : all) { + check(column_type_name(t) != nullptr, "column_type_name no null"); + check(column_type_icon(t) != nullptr, "column_type_icon no null"); + } + } + + // ---------------------------------------------------------------- + // Phase 3: stages vector, multi-stage TQL emit/apply, drill-down. + // ---------------------------------------------------------------- + + // --- State::ensure_stage0 crea stage 0 si vacio --- + { + State st; + check(st.stages.empty(), "phase3 state: stages vacio inicial"); + st.ensure_stage0(); + check(st.stages.size() == 1, "phase3 state: ensure_stage0 crea uno"); + check(st.active_stage == 0, "phase3 state: active_stage default 0"); + } + + // --- raw() y active() devuelven la misma stage cuando active=0 --- + { + State st; + Stage& r = st.raw(); + r.filters.push_back({0, Op::Eq, "x"}); + check(st.active().filters.size() == 1, "phase3 state: active==raw cuando active=0"); + check(st.stages[0].filters.size() == 1, "phase3 state: stages[0] visible via raw()"); + } + + // --- make_drill_filter helper --- + { + Filter f = make_drill_filter(2, "go"); + check(f.col == 2 && f.op == Op::Eq && f.value == "go", + "phase3 drill: make_drill_filter retorna Op::Eq"); + } + + // --- Multi-stage TQL emit: state con stage 0 + stage 1 --- + { + State st; + st.ensure_stage0(); + st.stages[0].filters.push_back({0, Op::Eq, "go"}); + Stage s1; + s1.breakouts.push_back("domain"); + s1.aggregations.push_back({AggFn::Count}); + s1.aggregations.push_back({AggFn::Avg, "n"}); + s1.sorts.push_back({"count", true}); + st.stages.push_back(std::move(s1)); + + std::vector hdrs = {"lang", "domain", "n"}; + std::vector tps = {ColumnType::String, ColumnType::String, ColumnType::Int}; + std::string out = tql::emit(st, hdrs, tps); + + check(out.find("breakout") != std::string::npos, "phase3 emit: contiene breakout"); + check(out.find("\"domain\"") != std::string::npos, "phase3 emit: col domain en breakout"); + check(out.find("aggregation") != std::string::npos, "phase3 emit: contiene aggregation"); + check(out.find("\"count\"") != std::string::npos, "phase3 emit: agg count"); + check(out.find("\"avg\"") != std::string::npos, "phase3 emit: agg avg"); + // 2 stages + size_t first = out.find(" {"); + size_t second = out.find(" {", first + 1); + check(first != std::string::npos && second != std::string::npos, + "phase3 emit: dos stage entries"); + } + + // --- Multi-stage TQL apply: stages chain --- + { + State st; + std::vector hdrs = {"lang", "domain", "n"}; + const char* cells_t[] = { + "go", "core", "10", + "go", "infra", "20", + "py", "core", "30", + "go", "core", "40", + }; + std::string text = R"LUA( +return { + stages = { + { filter = { {"=", "lang", "go"} } }, + { + breakout = {"domain"}, + aggregation = { {"count"}, {"avg", "n"} }, + sort = { {"desc", "count"} }, + }, + } +})LUA"; + std::string err; + bool ok = tql::apply(text, st, hdrs, std::vector{}, cells_t, 4, 3, &err); + check(ok, "phase3 apply: parsea multi-stage"); + check(st.stages.size() == 2, "phase3 apply: 2 stages creados"); + check(st.stages[0].filters.size() == 1 && + st.stages[0].filters[0].col == 0 && + st.stages[0].filters[0].value == "go", + "phase3 apply: stage 0 filter lang=go"); + check(st.stages[1].breakouts.size() == 1 && + st.stages[1].breakouts[0] == "domain", + "phase3 apply: stage 1 breakout=domain"); + check(st.stages[1].aggregations.size() == 2, + "phase3 apply: stage 1 tiene 2 aggregations"); + check(st.stages[1].aggregations[0].fn == AggFn::Count && + st.stages[1].aggregations[1].fn == AggFn::Avg && + st.stages[1].aggregations[1].col == "n", + "phase3 apply: aggregations [count, avg(n)]"); + check(st.stages[1].sorts.size() == 1 && + st.stages[1].sorts[0].col == "count" && + st.stages[1].sorts[0].desc == true, + "phase3 apply: stage 1 sort desc count"); + } + + // --- Chain execution: stage 0 feeds stage 1 (verifica compute_stage cadena) --- + { + std::vector hdrs = {"lang", "domain", "n"}; + std::vector tps = {ColumnType::String, ColumnType::String, ColumnType::Int}; + const char* cells_t[] = { + "go", "core", "10", + "go", "infra", "20", + "py", "core", "30", + "go", "core", "40", + }; + Stage s0; + s0.filters.push_back({0, Op::Eq, "go"}); // lang=go -> 3 rows + auto out0 = compute_stage(cells_t, 4, 3, hdrs, tps, s0); + check(out0.rows == 3, "phase3 chain: stage 0 produce 3 filas"); + + // Stage 1 sobre out0 + Stage s1; + s1.breakouts.push_back("domain"); + s1.aggregations.push_back({AggFn::Count}); + auto out1 = compute_stage(out0.cells.data(), out0.rows, out0.cols, + out0.headers, out0.types, s1); + check(out1.rows == 2, "phase3 chain: stage 1 produce 2 grupos (core,infra)"); + check(out1.cols == 2, "phase3 chain: stage 1 cols = breakout+count"); + check(out1.headers[0] == "domain" && out1.headers[1] == "count", + "phase3 chain: stage 1 headers"); + } + + // --- Round-trip multi-stage emit -> apply -> compare --- + { + State st0; + st0.ensure_stage0(); + Stage s1; + s1.breakouts.push_back("lang"); + s1.aggregations.push_back({AggFn::Count}); + s1.aggregations.push_back({AggFn::Sum, "n"}); + st0.stages.push_back(std::move(s1)); + + std::vector hdrs = {"lang", "n"}; + std::vector tps = {ColumnType::String, ColumnType::Int}; + std::string text = tql::emit(st0, hdrs, tps); + + State st1; + const char* cells_t[] = {"go","10","py","20"}; + std::string err; + bool ok = tql::apply(text, st1, hdrs, tps, cells_t, 2, 2, &err); + check(ok, "phase3 round-trip: apply OK"); + check(st1.stages.size() == 2, "phase3 round-trip: 2 stages preservados"); + check(st1.stages[1].breakouts.size() == 1 && + st1.stages[1].breakouts[0] == "lang", + "phase3 round-trip: breakout preservado"); + check(st1.stages[1].aggregations.size() == 2, + "phase3 round-trip: 2 aggregations preservadas"); + check(st1.stages[1].aggregations[1].fn == AggFn::Sum && + st1.stages[1].aggregations[1].col == "n", + "phase3 round-trip: sum(n) preservado"); + } + + // --- Emit con percentile: incluye arg --- + { + State st; + st.ensure_stage0(); + Stage s1; + s1.breakouts.push_back("k"); + Aggregation pct; pct.fn = AggFn::Percentile; pct.col = "n"; pct.arg = 0.95; + s1.aggregations.push_back(pct); + st.stages.push_back(std::move(s1)); + + std::vector hdrs = {"k", "n"}; + std::vector tps = {ColumnType::String, ColumnType::Int}; + std::string out = tql::emit(st, hdrs, tps); + check(out.find("\"percentile\"") != std::string::npos, + "phase3 emit percentile: fn token"); + check(out.find("0.95") != std::string::npos, + "phase3 emit percentile: arg 0.95"); + } + + // --- Drill-down logica: anadir Filter al stage previo --- + { + // Setup: state con 2 stages. Stage 1 groups by lang. Drill on lang=go + // anade Filter{lang=go} a stage 0 y active=0. + State st; + st.ensure_stage0(); + Stage s1; + s1.breakouts.push_back("lang"); + s1.aggregations.push_back({AggFn::Count}); + st.stages.push_back(std::move(s1)); + st.active_stage = 1; + + // Simular drill: agregar make_drill_filter(0, "go") a stage 0. + st.stages[0].filters.push_back(make_drill_filter(0, "go")); + st.active_stage = 0; + + check(st.stages[0].filters.size() == 1, + "phase3 drill: filter anadido a stage 0"); + check(st.stages[0].filters[0].op == Op::Eq && + st.stages[0].filters[0].value == "go", + "phase3 drill: filter Op::Eq value=go"); + check(st.stages.size() == 2, + "phase3 drill: stage 1 NO se borra (preserva camino)"); + check(st.active_stage == 0, + "phase3 drill: active vuelve a stage 0"); + } + + // === phase5: TQL validacion schema === + { + // version missing -> ok con warning + State st; + std::vector hdrs = {"a"}; + std::string err; + bool ok = tql::apply("return { display=\"table\", stages={}, columns={} }", + st, hdrs, std::vector{}, nullptr, 0, 1, &err); + check(ok, "phase5: version missing acepta"); + check(err.find("version missing") != std::string::npos, + "phase5: warning version missing presente"); + } + { + // version != 1 -> fail + State st; + std::vector hdrs = {"a"}; + std::string err; + bool ok = tql::apply("return { version=999, stages={}, columns={} }", + st, hdrs, std::vector{}, nullptr, 0, 1, &err); + check(!ok, "phase5: version != 1 rechaza"); + check(err.find("unsupported") != std::string::npos, + "phase5: error de version explicito"); + } + { + // version no-numero -> fail + State st; + std::vector hdrs = {"a"}; + std::string err; + bool ok = tql::apply("return { version=\"x\", stages={}, columns={} }", + st, hdrs, std::vector{}, nullptr, 0, 1, &err); + check(!ok, "phase5: version no-number rechaza"); + } + { + // unknown filter col -> warning + State st; + std::vector hdrs = {"a", "b"}; + const char* cells_t[] = {"x","1"}; + std::string err; + std::string text = + "return { version=1, stages={ { filter={{\"=\", \"missing\", \"v\"}} } }, columns={} }"; + bool ok = tql::apply(text, st, hdrs, std::vector{}, + cells_t, 1, 2, &err); + check(ok, "phase5: filter col desconocido NO bloquea"); + check(err.find("filter col") != std::string::npos && err.find("missing") != std::string::npos, + "phase5: warning filter col desconocido"); + } + { + // unknown agg fn -> warning + State st; + std::vector hdrs = {"a", "b"}; + const char* cells_t[] = {"x","1"}; + std::string err; + std::string text = + "return { version=1, stages={ {}, " + "{ breakout={\"a\"}, aggregation={ {\"weirdfn\", \"b\"} } } }, columns={} }"; + bool ok = tql::apply(text, st, hdrs, std::vector{}, + cells_t, 1, 2, &err); + check(ok, "phase5: agg fn desconocida NO bloquea"); + check(err.find("aggregation fn") != std::string::npos, + "phase5: warning agg fn desconocida"); + } + { + // agg sin col cuando la requiere -> warning + State st; + std::vector hdrs = {"a", "b"}; + std::string err; + std::string text = + "return { version=1, stages={ {}, " + "{ breakout={\"a\"}, aggregation={ {\"sum\"} } } }, columns={} }"; + bool ok = tql::apply(text, st, hdrs, std::vector{}, nullptr, 0, 2, &err); + check(ok, "phase5: agg sum sin col NO bloquea"); + check(err.find("requires a column") != std::string::npos, + "phase5: warning agg sin col"); + } + { + // unknown sort dir -> warning + State st; + std::vector hdrs = {"a"}; + std::string err; + std::string text = + "return { version=1, stages={ { sort={ {\"sideways\", \"a\"} } } }, columns={} }"; + bool ok = tql::apply(text, st, hdrs, std::vector{}, nullptr, 0, 1, &err); + check(ok, "phase5: sort dir desconocida NO bloquea"); + check(err.find("sort dir") != std::string::npos, + "phase5: warning sort dir desconocida"); + } + { + // unknown filter op -> warning + State st; + std::vector hdrs = {"a"}; + std::string err; + std::string text = + "return { version=1, stages={ { filter={ {\"~~\", \"a\", \"v\"} } } }, columns={} }"; + bool ok = tql::apply(text, st, hdrs, std::vector{}, nullptr, 0, 1, &err); + check(ok, "phase5: filter op desconocida NO bloquea"); + check(err.find("filter op") != std::string::npos, + "phase5: warning filter op desconocida"); + } + { + // TQL valido -> err vacio + State st; + std::vector hdrs = {"a", "b"}; + const char* cells_t[] = {"x","1","y","2"}; + std::string err; + std::string text = + "return { version=1, stages={ " + "{ filter={ {\"=\",\"a\",\"x\"} }, sort={ {\"asc\",\"a\"} } }, " + "{ breakout={\"a\"}, aggregation={ {\"count\"}, {\"sum\",\"b\"} } } " + "}, columns={} }"; + bool ok = tql::apply(text, st, hdrs, std::vector{}, + cells_t, 2, 2, &err); + check(ok && err.empty(), "phase5: TQL valido sin warnings"); + } + { + // emit() incluye cheatsheet header + State st; + std::vector hdrs = {"a"}; + std::vector tps = {ColumnType::String}; + std::string out = tql::emit(st, hdrs, tps); + check(out.find("-- TQL v1") != std::string::npos, + "phase5: emit incluye comentario cheatsheet"); + check(out.find("-- Stage 0 (Raw)") != std::string::npos, + "phase5: emit incluye explicacion de stages"); + } + + // === phase6: ViewMode tokens + TQL display round-trip === + { + check(std::string(view_mode_token(ViewMode::Table)) == "table", + "phase6: token table"); + check(std::string(view_mode_token(ViewMode::Bar)) == "bar", + "phase6: token bar"); + check(std::string(view_mode_token(ViewMode::Histogram)) == "histogram", + "phase6: token histogram"); + check(view_mode_from_token("scatter") == ViewMode::Scatter, + "phase6: from token scatter"); + check(view_mode_from_token("kpi_grid") == ViewMode::KPIGrid, + "phase6: from token kpi_grid"); + check(view_mode_from_token("nonsense") == ViewMode::Table, + "phase6: token desconocida -> Table default"); + int n; const ViewMode* arr = all_view_modes(&n); + check(arr != nullptr && n >= 20, "phase6: all_view_modes >= 20"); + check(view_mode_min_cols(ViewMode::Bubble) == 3, + "phase6: Bubble requiere 3 cols"); + check(view_mode_needs_category(ViewMode::Pie) == true, + "phase6: Pie necesita category"); + check(view_mode_needs_numeric(ViewMode::Histogram) == true, + "phase6: Histogram necesita numeric"); + } + { + // emit + apply preservan display + State st0; + st0.display = ViewMode::Scatter; + std::vector hdrs = {"a"}; + std::vector tps = {ColumnType::Int}; + std::string text = tql::emit(st0, hdrs, tps); + check(text.find("display = \"scatter\"") != std::string::npos, + "phase6: emit contiene display=scatter"); + + State st1; + const char* cells_t[] = {"1","2","3"}; + std::string err; + bool ok = tql::apply(text, st1, hdrs, tps, cells_t, 3, 1, &err); + check(ok, "phase6: apply ok"); + check(st1.display == ViewMode::Scatter, + "phase6: display preservado tras round-trip"); + } + { + // display desconocido -> Table default + warning + State st; + std::vector hdrs = {"a"}; + std::string err; + std::string text = + "return { version=1, display=\"weird\", stages={}, columns={} }"; + bool ok = tql::apply(text, st, hdrs, std::vector{}, nullptr, 0, 1, &err); + check(ok, "phase6: display unknown NO bloquea"); + check(st.display == ViewMode::Table, + "phase6: display unknown -> Table default"); + check(err.find("unknown display") != std::string::npos, + "phase6: warning unknown display"); + } + + // === phase7b: TQL views round-trip === + { + State st0; + st0.display = ViewMode::Bar; + st0.viz_config.cat_col = "country"; + st0.viz_config.y_cols = {"sales"}; + st0.viz_config.primary_color = 0xFF00FF00; + + VizPanel p; + p.display = ViewMode::Pie; + p.config.cat_col = "country"; + p.config.y_cols = {"profit"}; + p.config.hist_bins = 0; + p.config.show_legend = false; + st0.extra_panels.push_back(p); + + VizPanel p2; + p2.display = ViewMode::Histogram; + p2.config.y_cols = {"sales"}; + p2.config.hist_bins = 20; + st0.extra_panels.push_back(p2); + + std::vector hdrs = {"country", "sales", "profit"}; + std::vector tps = {ColumnType::String, ColumnType::Int, ColumnType::Int}; + std::string text = tql::emit(st0, hdrs, tps); + check(text.find("views = {") != std::string::npos, + "phase7b: emit contiene bloque views"); + check(text.find("display = \"bar\"") != std::string::npos, + "phase7b: emit panel 0 display=bar"); + check(text.find("display = \"pie\"") != std::string::npos, + "phase7b: emit panel 1 display=pie"); + check(text.find("display = \"histogram\"") != std::string::npos, + "phase7b: emit panel 2 display=histogram"); + + State st1; + const char* cells_t[] = {"es","100","20","fr","200","30"}; + std::string err; + bool ok = tql::apply(text, st1, hdrs, tps, cells_t, 2, 3, &err); + check(ok, "phase7b: apply views ok"); + check(st1.display == ViewMode::Bar, "phase7b: main display preservado"); + check(st1.viz_config.cat_col == "country", + "phase7b: main cat_col preservado"); + check(st1.extra_panels.size() == 2, + "phase7b: 2 extra panels preservados"); + if (st1.extra_panels.size() >= 2) { + check(st1.extra_panels[0].display == ViewMode::Pie, + "phase7b: extra[0] = pie"); + check(st1.extra_panels[1].display == ViewMode::Histogram, + "phase7b: extra[1] = histogram"); + check(st1.extra_panels[1].config.hist_bins == 20, + "phase7b: hist_bins preservado"); + check(st1.extra_panels[0].config.show_legend == false, + "phase7b: show_legend=false preservado"); + } + } + + // === phase9: joins MBQL-style === + { + // Left table: users + std::vector lh = {"id", "name"}; + std::vector lt = {ColumnType::Int, ColumnType::String}; + const char* lc[] = {"1","alice", "2","bob", "3","carol"}; + + // Right table: orders + TableInput right; + right.name = "orders"; + right.headers = {"user_id", "amount"}; + right.types = {ColumnType::Int, ColumnType::Int}; + const char* rc[] = {"1","100", "1","200", "2","50", "4","999"}; + right.cells = rc; + right.rows = 4; + right.cols = 2; + + Join jn; + jn.alias = "o"; + jn.source = "orders"; + jn.on = {{"id", "user_id"}}; + jn.strategy = JoinStrategy::Inner; + + auto out = join_tables(lc, 3, 2, lh, lt, right, jn); + check(out.cols == 4, "phase9 inner: 4 cols"); + check(out.rows == 3, "phase9 inner: 3 matches (1+1+2 minus carol)"); + check(out.headers[2] == "o.user_id", + "phase9 inner: header prefijado alias.col"); + check(out.headers[3] == "o.amount", + "phase9 inner: header amount prefijado"); + + // Left join: alice/alice/bob/carol(empty) + jn.strategy = JoinStrategy::Left; + auto out_l = join_tables(lc, 3, 2, lh, lt, right, jn); + check(out_l.rows == 4, "phase9 left: 4 filas (3 matches + carol empty)"); + + // Right join: alice/alice/bob/empty(user 4) + jn.strategy = JoinStrategy::Right; + auto out_r = join_tables(lc, 3, 2, lh, lt, right, jn); + check(out_r.rows == 4, "phase9 right: 4 filas (3 matches + user 4 empty)"); + + // Full join: 3 matches + carol-empty + user4-empty = 5 + jn.strategy = JoinStrategy::Full; + auto out_f = join_tables(lc, 3, 2, lh, lt, right, jn); + check(out_f.rows == 5, "phase9 full: 5 filas"); + + // Sin alias -> headers del right sin prefijo (preservar nombre) + jn.alias = ""; + jn.strategy = JoinStrategy::Inner; + auto out_nopfx = join_tables(lc, 3, 2, lh, lt, right, jn); + check(out_nopfx.headers[2] == "user_id", + "phase9: sin alias headers no prefijados"); + + // Fields filter: solo "amount" + jn.alias = "o"; + jn.fields = {"amount"}; + auto out_ff = join_tables(lc, 3, 2, lh, lt, right, jn); + check(out_ff.cols == 3, "phase9: fields filter -> solo 1 col del right"); + check(out_ff.headers[2] == "o.amount", + "phase9: fields filter respeta alias"); + } + { + // Multi-key join + std::vector lh = {"y", "m", "v"}; + std::vector lt = {ColumnType::Int, ColumnType::Int, ColumnType::Int}; + const char* lc[] = {"2020","1","10", "2020","2","20", "2021","1","30"}; + + TableInput right; + right.name = "tax"; + right.headers = {"year", "month", "rate"}; + right.types = {ColumnType::Int, ColumnType::Int, ColumnType::Float}; + const char* rc[] = {"2020","1","0.1", "2020","2","0.15", "2021","1","0.2"}; + right.cells = rc; right.rows = 3; right.cols = 3; + + Join jn; + jn.alias = "t"; + jn.source = "tax"; + jn.on = {{"y","year"}, {"m","month"}}; + jn.strategy = JoinStrategy::Inner; + + auto out = join_tables(lc, 3, 3, lh, lt, right, jn); + check(out.rows == 3, "phase9 multi-key: 3 matches"); + check(out.cols == 6, "phase9 multi-key: 3 left + 3 right"); + } + { + // TQL main_source round-trip + State st0; + st0.main_source = "users"; + std::vector hdrs = {"a"}; + std::vector tps = {ColumnType::String}; + std::string text = tql::emit(st0, hdrs, tps); + check(text.find("main_source = \"users\"") != std::string::npos, + "phase9 TQL: emit main_source"); + State st1; + std::string err; + bool ok = tql::apply(text, st1, hdrs, tps, nullptr, 0, 1, &err); + check(ok, "phase9 TQL: apply main_source ok"); + check(st1.main_source == "users", "phase9 TQL: main_source preservado"); + } + { + // TQL emit/apply joins + State st0; + Join jn; + jn.alias = "o"; jn.source = "orders"; jn.strategy = JoinStrategy::Inner; + jn.on = {{"user_id", "user_id"}, {"region", "region"}}; + jn.fields = {"amount", "tax"}; + st0.joins.push_back(jn); + + std::vector hdrs = {"user_id","region","name"}; + std::vector tps = {ColumnType::Int, ColumnType::String, ColumnType::String}; + std::string text = tql::emit(st0, hdrs, tps); + check(text.find("joins = {") != std::string::npos, "phase9 TQL: emit joins block"); + check(text.find("strategy = \"inner\"") != std::string::npos, "phase9 TQL: emit strategy"); + check(text.find("fields = {") != std::string::npos, "phase9 TQL: emit fields"); + + State st1; + std::string err; + bool ok = tql::apply(text, st1, hdrs, tps, nullptr, 0, 3, &err); + check(ok, "phase9 TQL: apply ok"); + check(st1.joins.size() == 1, "phase9 TQL: 1 join preservado"); + if (!st1.joins.empty()) { + check(st1.joins[0].alias == "o", "phase9 TQL: alias preservado"); + check(st1.joins[0].strategy == JoinStrategy::Inner, "phase9 TQL: strategy preservada"); + check(st1.joins[0].on.size() == 2, "phase9 TQL: multi-key on preservada"); + check(st1.joins[0].fields.size() == 2, "phase9 TQL: fields preservados"); + } + } + { + // resolve_main_idx + std::vector empty; + check(resolve_main_idx(empty, "") == -1, "phase9: tables vacio -> -1"); + TableInput a; a.name = "a"; + TableInput b; b.name = "b"; + TableInput c; c.name = "c"; + std::vector t3 = {a, b, c}; + check(resolve_main_idx(t3, "") == 0, "phase9: source vacio -> idx 0"); + check(resolve_main_idx(t3, "b") == 1, "phase9: source match -> idx exacto"); + check(resolve_main_idx(t3, "c") == 2, "phase9: source match c -> 2"); + check(resolve_main_idx(t3, "nope") == 0, "phase9: source desconocido -> idx 0"); + } + { + // Strategy tokens round-trip + check(std::string(join_strategy_token(JoinStrategy::Left)) == "left", "phase9: token left"); + check(std::string(join_strategy_token(JoinStrategy::Inner)) == "inner","phase9: token inner"); + check(join_strategy_from_token("right") == JoinStrategy::Right, "phase9: parse right"); + check(join_strategy_from_token("full") == JoinStrategy::Full, "phase9: parse full"); + check(join_strategy_from_token("nope") == JoinStrategy::Left, "phase9: parse fallback left"); + } + std::printf("\n=== %d passed, %d failed ===\n", passed, failed); return failed == 0 ? 0 : 1; } diff --git a/playground/tables/tql.cpp b/playground/tables/tql.cpp new file mode 100644 index 0000000..3bcc3ce --- /dev/null +++ b/playground/tables/tql.cpp @@ -0,0 +1,910 @@ +#include "tql.h" +#include "lua_engine.h" + +extern "C" { +#include "lua.h" +#include "lualib.h" +#include "lauxlib.h" +} + +#include +#include +#include +#include + +namespace tql { + +using namespace data_table; + +namespace { + +int find_orig_col(const std::vector& headers, const std::string& name) { + for (size_t i = 0; i < headers.size(); ++i) if (headers[i] == name) return (int)i; + return -1; +} + +int find_derived_idx(const std::vector& d, const std::string& name) { + for (size_t i = 0; i < d.size(); ++i) if (d[i].name == name) return (int)i; + return -1; +} + +Op parse_op(const std::string& s) { + if (s == "=") return Op::Eq; + if (s == "!=") return Op::Neq; + if (s == ">") return Op::Gt; + if (s == ">=") return Op::Gte; + if (s == "<") return Op::Lt; + if (s == "<=") return Op::Lte; + if (s == "contains") return Op::Contains; + if (s == "!contains") return Op::NotContains; + if (s == "starts") return Op::StartsWith; + if (s == "ends") return Op::EndsWith; + return Op::Eq; +} + +std::string lua_to_string(lua_State* L, int idx) { + if (lua_isnil(L, idx)) return ""; + if (lua_isboolean(L, idx)) return lua_toboolean(L, idx) ? "true" : "false"; + size_t n = 0; + const char* s = luaL_tolstring(L, idx, &n); + std::string out(s, n); + lua_pop(L, 1); + return out; +} + +} // anon + +std::string lua_string_literal(const std::string& s) { + std::string out; + out.reserve(s.size() + 4); + out += '"'; + for (char c : s) { + switch (c) { + case '\\': out += "\\\\"; break; + case '"': out += "\\\""; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if ((unsigned char)c < 0x20) { + char b[8]; std::snprintf(b, sizeof(b), "\\%d", (unsigned char)c); + out += b; + } else out += c; + } + } + out += '"'; + return out; +} + +std::string color_to_hex(unsigned int c) { + unsigned int r = c & 0xFF; + unsigned int g = (c >> 8) & 0xFF; + unsigned int b = (c >> 16) & 0xFF; + unsigned int a = (c >> 24) & 0xFF; + char buf[16]; + if (a == 0xFF) std::snprintf(buf, sizeof(buf), "#%02x%02x%02x", r, g, b); + else std::snprintf(buf, sizeof(buf), "#%02x%02x%02x%02x", r, g, b, a); + return buf; +} + +unsigned int hex_to_color(const std::string& s) { + if (s.size() < 7 || s[0] != '#') return 0xFFFFFFFF; + auto hex2 = [&](size_t i) -> unsigned int { + unsigned int v = 0; + if (i + 1 < s.size()) std::sscanf(s.c_str() + i, "%2x", &v); + return v; + }; + unsigned int r = hex2(1), g = hex2(3), b = hex2(5); + unsigned int a = (s.size() >= 9) ? hex2(7) : 0xFF; + return r | (g << 8) | (b << 16) | (a << 24); +} + +ColumnType column_type_from_string(const std::string& s) { + if (s == "string") return ColumnType::String; + if (s == "int") return ColumnType::Int; + if (s == "float") return ColumnType::Float; + if (s == "bool") return ColumnType::Bool; + if (s == "date") return ColumnType::Date; + if (s == "json") return ColumnType::Json; + return ColumnType::Auto; +} + +// Helper: header del Stage 0 dado un col idx eff. Para stages 1+ no aplica +// (los stage outputs tienen sus propios headers). +namespace { +const char* agg_fn_token(AggFn f) { + switch (f) { + case AggFn::Count: return "count"; + case AggFn::Sum: return "sum"; + case AggFn::Avg: return "avg"; + case AggFn::Min: return "min"; + case AggFn::Max: return "max"; + case AggFn::Distinct: return "distinct"; + case AggFn::Stddev: return "stddev"; + case AggFn::Median: return "median"; + case AggFn::P25: return "p25"; + case AggFn::P75: return "p75"; + case AggFn::P90: return "p90"; + case AggFn::P99: return "p99"; + case AggFn::Percentile: return "percentile"; + } + return "?"; +} + +AggFn agg_fn_from_string(const std::string& s) { + if (s == "count") return AggFn::Count; + if (s == "sum") return AggFn::Sum; + if (s == "avg") return AggFn::Avg; + if (s == "min") return AggFn::Min; + if (s == "max") return AggFn::Max; + if (s == "distinct") return AggFn::Distinct; + if (s == "stddev") return AggFn::Stddev; + if (s == "median") return AggFn::Median; + if (s == "p25") return AggFn::P25; + if (s == "p75") return AggFn::P75; + if (s == "p90") return AggFn::P90; + if (s == "p99") return AggFn::P99; + if (s == "percentile") return AggFn::Percentile; + return AggFn::Count; +} +} // anon + +std::string emit(const State& state, + const std::vector& headers, + const std::vector& types) +{ + int orig_cols = (int)headers.size(); + const Stage& raw = state.raw(); + int eff_cols = orig_cols + (int)raw.derived.size(); + + // Build effective headers + types (same indexing as col_visible/order) + std::vector eff_headers(eff_cols); + std::vector eff_types(eff_cols); + for (int c = 0; c < orig_cols; ++c) { + eff_headers[c] = headers[c]; + eff_types[c] = (c < (int)types.size()) ? types[c] : ColumnType::Auto; + } + for (int k = 0; k < (int)raw.derived.size(); ++k) { + eff_headers[orig_cols + k] = raw.derived[k].name; + eff_types[orig_cols + k] = raw.derived[k].type; + } + + // Build order positions: col_idx -> visual order (1-based) + std::unordered_map order_pos; + for (size_t i = 0; i < state.col_order.size(); ++i) { + order_pos[state.col_order[i]] = (int)i + 1; + } + + auto emit_filter_block = [&](const std::vector& filters, + const std::vector& stage_headers, + const char* indent) -> std::string { + if (filters.empty()) return {}; + std::string s; + s += indent; s += "filter = {\n"; + for (const auto& f : filters) { + std::string col_name = (f.col >= 0 && f.col < (int)stage_headers.size()) + ? stage_headers[f.col] : ""; + s += indent; s += " {"; + s += lua_string_literal(op_label(f.op)); + s += ", "; + s += lua_string_literal(col_name); + s += ", "; + s += lua_string_literal(f.value); + s += "},\n"; + } + s += indent; s += "},\n"; + return s; + }; + + auto emit_sort_block = [&](const std::vector& sorts, + const char* indent) -> std::string { + if (sorts.empty()) return {}; + std::string s; + s += indent; s += "sort = {\n"; + for (const auto& sc : sorts) { + s += indent; s += " {"; + s += lua_string_literal(sc.desc ? "desc" : "asc"); + s += ", "; + s += lua_string_literal(sc.col); + s += "},\n"; + } + s += indent; s += "},\n"; + return s; + }; + + std::string out; + out += "-- TQL v1 (Table Query Language). Round-trip de State <-> Lua.\n"; + out += "-- Schema:\n"; + out += "-- version = 1 -- bump si breaking change\n"; + out += "-- display = \"table\" -- table|bar|line|pie (futuro)\n"; + out += "-- stages = { stage0, stage1, ... } -- pipeline; stage 0 = Raw\n"; + out += "-- columns = { {name,type,visible,order,color_rules}, ... }\n"; + out += "--\n"; + out += "-- Stage 0 (Raw): filter + expressions + sort\n"; + out += "-- Stage N (Grouped): filter + breakout + aggregation + sort\n"; + out += "--\n"; + out += "-- filter: {{op, col, val}, ...} op in =,!=,>,>=,<,<=,contains,!contains,starts,ends\n"; + out += "-- expressions: {[name] = \"lua_body\"} ej: [\"total\"] = \"return [a] + [b]\"\n"; + out += "-- breakout: {\"col1\", \"col2\"} group by\n"; + out += "-- aggregation: {{fn, col, arg?}, ...} fn in count,sum,avg,min,max,distinct,stddev,median,p25,p75,p90,p99,percentile\n"; + out += "-- sort: {{dir, col}, ...} dir in asc,desc\n"; + out += "return {\n"; + out += " version = 1,\n"; + out += " display = "; + out += lua_string_literal(view_mode_token(state.display)); + out += ",\n"; + if (!state.main_source.empty()) { + out += " main_source = "; + out += lua_string_literal(state.main_source); + out += ",\n"; + } + + // joins (antes de stages, materializa input) + if (!state.joins.empty()) { + out += " joins = {\n"; + for (const auto& jn : state.joins) { + out += " {alias = " + lua_string_literal(jn.alias); + out += ", source = " + lua_string_literal(jn.source); + out += ", strategy = " + lua_string_literal(join_strategy_token(jn.strategy)); + out += ", on = {"; + for (size_t i = 0; i < jn.on.size(); ++i) { + if (i) out += ", "; + out += "{" + lua_string_literal(jn.on[i].first) + ", " + + lua_string_literal(jn.on[i].second) + "}"; + } + out += "}"; + if (!jn.fields.empty()) { + out += ", fields = {"; + for (size_t i = 0; i < jn.fields.size(); ++i) { + if (i) out += ", "; + out += lua_string_literal(jn.fields[i]); + } + out += "}"; + } + out += "},\n"; + } + out += " },\n"; + } + + out += " stages = {\n"; + + // Recorre todos los stages; stage 0 tiene formato Raw (filter+expr+sort), + // stages 1+ tienen formato Grouped (filter+breakout+aggregation+sort). + // Headers para resolver col indices de filters/sorts se computan stage por + // stage simulando la cadena. + std::vector cur_headers = headers; // stage input headers + // Para stage 0 raw, los headers incluyen orig + derived. + // Construye cur_headers iniciales (= orig); derived se anaden al pasar stage 0. + + for (int si = 0; si < (int)state.stages.size(); ++si) { + const Stage& stg = state.stages[si]; + out += " {\n"; + + if (si == 0) { + // Stage 0: orig headers + derived seran disponibles tras expressions. + // Para los filter col indices, asumimos van con cur_headers = orig. + // (data_table.cpp solo aplica filters a orig cols al guardar; si en + // futuro stage 0 admite filter sobre derived, se traduce a name.) + std::vector s0_headers = headers; + // Filters + out += emit_filter_block(stg.filters, s0_headers, " "); + + // Expressions + if (!stg.derived.empty()) { + bool any = false; + for (const auto& d : stg.derived) if (!d.formula.empty()) { any = true; break; } + if (any) { + out += " expressions = {\n"; + for (const auto& d : stg.derived) { + if (d.formula.empty()) continue; + out += " ["; + out += lua_string_literal(d.name); + out += "] = "; + out += lua_string_literal(d.formula); + out += ",\n"; + } + out += " },\n"; + } + } + + // Sort (sort.col es string en nuevo modelo). + out += emit_sort_block(stg.sorts, " "); + + // Avanza cur_headers para siguiente stage: orig + derived. + for (const auto& d : stg.derived) cur_headers.push_back(d.name); + } else { + // Stage 1+: filter (sobre output del previo, cur_headers). + out += emit_filter_block(stg.filters, cur_headers, " "); + + // breakout + if (!stg.breakouts.empty()) { + out += " breakout = {"; + for (size_t i = 0; i < stg.breakouts.size(); ++i) { + if (i > 0) out += ", "; + out += lua_string_literal(stg.breakouts[i]); + } + out += "},\n"; + } + + // aggregation + if (!stg.aggregations.empty()) { + out += " aggregation = {\n"; + for (const auto& a : stg.aggregations) { + out += " {"; + out += lua_string_literal(agg_fn_token(a.fn)); + if (a.fn != AggFn::Count) { + out += ", "; + out += lua_string_literal(a.col); + } + if (a.fn == AggFn::Percentile) { + char buf[32]; std::snprintf(buf, sizeof(buf), "%g", a.arg); + out += ", "; out += buf; + } + out += "},\n"; + } + out += " },\n"; + } + + // sort + out += emit_sort_block(stg.sorts, " "); + + // Avanza cur_headers para siguiente stage: breakouts + agg aliases. + std::vector next; + for (const auto& b : stg.breakouts) next.push_back(b); + for (const auto& a : stg.aggregations) next.push_back(aggregation_alias(a)); + cur_headers = std::move(next); + } + + out += " },\n"; + } + out += " },\n"; + + // columns (per-col render config) — siempre referidas a los effective cols + // del STAGE 0 (asumimos viz state para stage 0 / raw). Renderizar columns + // por cada stage no aporta v1. + out += " columns = {\n"; + for (int c = 0; c < eff_cols; ++c) { + out += " {"; + out += "name = " + lua_string_literal(eff_headers[c]); + out += ", type = " + lua_string_literal(column_type_name(eff_types[c])); + bool vis = (c < (int)state.col_visible.size()) ? state.col_visible[c] : true; + out += std::string(", visible = ") + (vis ? "true" : "false"); + int order = order_pos.count(c) ? order_pos[c] : c + 1; + out += ", order = " + std::to_string(order); + // color rules for this col + bool first = true; + for (const auto& cr : state.color_rules) { + if (cr.col != c) continue; + if (first) { out += ", color_rules = {"; first = false; } + else { out += ", "; } + out += "{equals = " + lua_string_literal(cr.equals); + out += ", color = " + lua_string_literal(color_to_hex(cr.color)) + "}"; + } + if (!first) out += "}"; + out += "},\n"; + } + out += " },\n"; + + // views (extra viz panels — viz adicional sobre mismos stages) + auto emit_view = [&](const VizPanel& p) -> std::string { + std::string s = " {"; + s += "display = " + lua_string_literal(view_mode_token(p.display)); + if (!p.config.x_col.empty()) s += ", x_col = " + lua_string_literal(p.config.x_col); + if (!p.config.cat_col.empty()) s += ", cat_col = " + lua_string_literal(p.config.cat_col); + if (!p.config.size_col.empty()) s += ", size_col = "+ lua_string_literal(p.config.size_col); + if (!p.config.y_cols.empty()) { + s += ", y_cols = {"; + for (size_t i = 0; i < p.config.y_cols.size(); ++i) { + if (i) s += ", "; + s += lua_string_literal(p.config.y_cols[i]); + } + s += "}"; + } + if (p.config.primary_color != 0) + s += ", color = " + lua_string_literal(color_to_hex(p.config.primary_color)); + if (p.config.hist_bins > 0) + s += ", hist_bins = " + std::to_string(p.config.hist_bins); + if (p.config.pie_radius > 0) + s += ", pie_radius = " + std::to_string(p.config.pie_radius); + if (!p.config.show_legend) s += ", show_legend = false"; + if (p.config.show_markers) s += ", show_markers = true"; + if (p.config.locked) s += ", locked = true"; + s += "},\n"; + return s; + }; + + out += " views = {\n"; + // Panel 0 = main viz + VizPanel main_p; + main_p.display = state.display; + main_p.config = state.viz_config; + out += emit_view(main_p); + for (const auto& p : state.extra_panels) out += emit_view(p); + out += " },\n"; + + out += " visualization_settings = {},\n"; + out += "}\n"; + return out; +} + +bool apply(const std::string& lua_text, State& state, + const std::vector& headers, + const std::vector& /*types*/, + const char* const* cells, int rows, int orig_cols, + std::string* err) +{ + std::vector warns; + auto warn = [&](const std::string& m) { warns.push_back(m); }; + auto finish_with_warns = [&](bool ok) -> bool { + if (err && !warns.empty()) { + std::string j; + for (size_t i = 0; i < warns.size(); ++i) { + if (i) j += "; "; + j += warns[i]; + } + *err = j; + } + return ok; + }; + + lua_State* L = lua_engine::raw_state(); + if (!L) { if (err) *err = "lua engine null"; return false; } + + if (luaL_loadbufferx(L, lua_text.data(), lua_text.size(), "tql", "t") != LUA_OK) { + if (err) *err = lua_tostring(L, -1) ? lua_tostring(L, -1) : "load error"; + lua_pop(L, 1); + return false; + } + if (lua_pcall(L, 0, 1, 0) != LUA_OK) { + if (err) *err = lua_tostring(L, -1) ? lua_tostring(L, -1) : "exec error"; + lua_pop(L, 1); + return false; + } + if (!lua_istable(L, -1)) { + if (err) *err = "TQL root must be a table"; + lua_pop(L, 1); + return false; + } + + // main_source + lua_getfield(L, -1, "main_source"); + if (lua_isstring(L, -1)) state.main_source = lua_tostring(L, -1); + else state.main_source.clear(); + lua_pop(L, 1); + + // display + lua_getfield(L, -1, "display"); + if (lua_isstring(L, -1)) { + std::string d = lua_tostring(L, -1); + ViewMode m = view_mode_from_token(d.c_str()); + state.display = m; + if (d != "table" && std::strcmp(view_mode_token(m), d.c_str()) != 0) { + warn("unknown display \"" + d + "\" (defaulting to table)"); + } + } + lua_pop(L, 1); + + // Validar version. + lua_getfield(L, -1, "version"); + if (lua_isnil(L, -1)) { + warn("version missing (assuming 1)"); + } else if (!lua_isnumber(L, -1)) { + if (err) *err = "version must be a number"; + lua_pop(L, 2); + return false; + } else { + int v = (int)lua_tointeger(L, -1); + if (v != 1) { + char buf[64]; std::snprintf(buf, sizeof(buf), "unsupported TQL version %d (expected 1)", v); + if (err) *err = buf; + lua_pop(L, 2); + return false; + } + } + lua_pop(L, 1); + + // Reset partes mutables. Liberar lua_ids antes. + for (auto& s : state.stages) { + for (auto& d : s.derived) { + if (d.lua_id >= 0) lua_engine::release(lua_engine::get(), d.lua_id); + } + } + state.stages.clear(); + state.active_stage = 0; + state.color_rules.clear(); + + // ---- Walk joins[] ---- + state.joins.clear(); + lua_getfield(L, -1, "joins"); + if (lua_istable(L, -1)) { + int nj = (int)lua_rawlen(L, -1); + for (int i = 1; i <= nj; ++i) { + lua_rawgeti(L, -1, i); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + Join jn; + lua_getfield(L, -1, "alias"); + if (lua_isstring(L, -1)) jn.alias = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "source"); + if (lua_isstring(L, -1)) jn.source = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "strategy"); + if (lua_isstring(L, -1)) jn.strategy = join_strategy_from_token(lua_tostring(L, -1)); + lua_pop(L, 1); + lua_getfield(L, -1, "on"); + if (lua_istable(L, -1)) { + int on_n = (int)lua_rawlen(L, -1); + for (int k = 1; k <= on_n; ++k) { + lua_rawgeti(L, -1, k); + if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) { + lua_rawgeti(L, -1, 1); std::string a = lua_to_string(L, -1); lua_pop(L, 1); + lua_rawgeti(L, -1, 2); std::string b = lua_to_string(L, -1); lua_pop(L, 1); + jn.on.push_back({a, b}); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + lua_getfield(L, -1, "fields"); + if (lua_istable(L, -1)) { + int fn_n = (int)lua_rawlen(L, -1); + for (int k = 1; k <= fn_n; ++k) { + lua_rawgeti(L, -1, k); + if (lua_isstring(L, -1)) jn.fields.emplace_back(lua_tostring(L, -1)); + lua_pop(L, 1); + } + } + lua_pop(L, 1); + state.joins.push_back(jn); + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + // ---- Walk stages[] ---- + lua_getfield(L, -1, "stages"); + if (lua_istable(L, -1)) { + int n_stages = (int)lua_rawlen(L, -1); + // Headers efectivos por stage para resolver filter/sort col indices. + std::vector cur_headers = headers; + + for (int si = 1; si <= n_stages; ++si) { + lua_rawgeti(L, -1, si); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + + Stage stg; + + // Stage 0 expressions (solo aplica a si == 1, pero permitimos en + // cualquier stage por simetria — el UI no las expone en stages 1+). + lua_getfield(L, -1, "expressions"); + if (lua_istable(L, -1)) { + lua_pushnil(L); + while (lua_next(L, -2) != 0) { + if (lua_isstring(L, -2) && lua_isstring(L, -1)) { + std::string name = lua_tostring(L, -2); + std::string formula = lua_tostring(L, -1); + std::string cerr; + int id = lua_engine::compile(lua_engine::get(), formula, &cerr); + DerivedColumn d; + d.source_col = -1; + d.name = name; + d.formula = formula; + d.lua_id = id; + d.compile_error = (id < 0) ? cerr : ""; + if (id >= 0 && si == 1) { + // auto-detect tipo via sample (solo para stage 0). + int sample = std::min(64, rows); + std::vector samples_str; + std::vector samples_ptr; + std::vector hn_storage = headers; + std::unordered_map n2c; + for (int c = 0; c < orig_cols && c < (int)hn_storage.size(); ++c) { + n2c[hn_storage[c]] = c; + } + for (int r = 0; r < sample; ++r) { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &n2c; + std::string e; + samples_str.emplace_back( + lua_engine::eval(lua_engine::get(), id, ctx, &e)); + } + for (auto& s : samples_str) samples_ptr.push_back(s.c_str()); + d.type = auto_detect_type(samples_ptr.data(), + (int)samples_ptr.size(), 1, 0); + } else { + d.type = ColumnType::String; + } + stg.derived.push_back(d); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + // filter + lua_getfield(L, -1, "filter"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 3) { + lua_rawgeti(L, -1, 1); std::string op = lua_to_string(L, -1); lua_pop(L, 1); + lua_rawgeti(L, -1, 2); std::string col_name = lua_to_string(L, -1); lua_pop(L, 1); + lua_rawgeti(L, -1, 3); std::string val = lua_to_string(L, -1); lua_pop(L, 1); + int ci = find_orig_col(cur_headers, col_name); + if (ci >= 0) { + stg.filters.push_back({ci, parse_op(op), val}); + } else { + warn("stage " + std::to_string(si - 1) + ": filter col \"" + col_name + "\" not found"); + } + if (op != "=" && op != "!=" && op != ">" && op != ">=" && + op != "<" && op != "<=" && op != "contains" && + op != "!contains" && op != "starts" && op != "ends") { + warn("stage " + std::to_string(si - 1) + ": unknown filter op \"" + op + "\" (defaulting to =)"); + } + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + // breakout (solo aplica stages >= 1, no-op silencioso si stage 0) + lua_getfield(L, -1, "breakout"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (lua_isstring(L, -1)) { + std::string bn = lua_tostring(L, -1); + if (find_orig_col(cur_headers, bn) < 0) { + warn("stage " + std::to_string(si - 1) + ": breakout col \"" + bn + "\" not in input headers"); + } + stg.breakouts.emplace_back(bn); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + // aggregation + lua_getfield(L, -1, "aggregation"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 1) { + Aggregation a; + lua_rawgeti(L, -1, 1); + std::string fn_name = lua_to_string(L, -1); + lua_pop(L, 1); + bool known = (fn_name == "count" || fn_name == "sum" || fn_name == "avg" || + fn_name == "min" || fn_name == "max" || fn_name == "distinct" || + fn_name == "stddev"|| fn_name == "median" || + fn_name == "p25" || fn_name == "p75" || fn_name == "p90" || + fn_name == "p99" || fn_name == "percentile"); + if (!known) { + warn("stage " + std::to_string(si - 1) + ": unknown aggregation fn \"" + fn_name + "\" (defaulting to count)"); + } + a.fn = agg_fn_from_string(fn_name); + if (lua_rawlen(L, -1) >= 2) { + lua_rawgeti(L, -1, 2); + a.col = lua_to_string(L, -1); + lua_pop(L, 1); + if (a.fn != AggFn::Count && find_orig_col(cur_headers, a.col) < 0) { + warn("stage " + std::to_string(si - 1) + ": aggregation col \"" + a.col + "\" not in input headers"); + } + } else if (a.fn != AggFn::Count) { + warn("stage " + std::to_string(si - 1) + ": aggregation \"" + fn_name + "\" requires a column"); + } + if (lua_rawlen(L, -1) >= 3) { + lua_rawgeti(L, -1, 3); + if (lua_isnumber(L, -1)) a.arg = lua_tonumber(L, -1); + lua_pop(L, 1); + } + stg.aggregations.push_back(a); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + // sort + lua_getfield(L, -1, "sort"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) { + lua_rawgeti(L, -1, 1); std::string dir = lua_to_string(L, -1); lua_pop(L, 1); + lua_rawgeti(L, -1, 2); std::string col = lua_to_string(L, -1); lua_pop(L, 1); + SortClause sc; + sc.col = col; + sc.desc = (dir == "desc"); + if (dir != "asc" && dir != "desc") { + warn("stage " + std::to_string(si - 1) + ": unknown sort dir \"" + dir + "\" (defaulting to asc)"); + } + stg.sorts.push_back(sc); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + state.stages.push_back(std::move(stg)); + + // Advance cur_headers para resolver filter/sort col del siguiente stage. + const Stage& last = state.stages.back(); + if (si == 1) { + // Stage 0: cur_headers = orig + derived (sin breakouts/agg). + for (const auto& d : last.derived) cur_headers.push_back(d.name); + } else { + if (!last.breakouts.empty() || !last.aggregations.empty()) { + std::vector next; + for (const auto& b : last.breakouts) next.push_back(b); + for (const auto& a : last.aggregations) next.push_back(aggregation_alias(a)); + cur_headers = std::move(next); + } + } + + lua_pop(L, 1); // pop stage entry + } + } + lua_pop(L, 1); // stages + + state.ensure_stage0(); + + // ---- Walk columns (per-col render config) ---- + int eff_cols = orig_cols + (int)state.raw().derived.size(); + lua_getfield(L, -1, "columns"); + if (lua_istable(L, -1)) { + state.col_visible.assign(eff_cols, true); + std::vector> order_pairs; + std::vector seen(eff_cols, false); + + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + + lua_getfield(L, -1, "name"); + std::string nm = lua_to_string(L, -1); + lua_pop(L, 1); + + int col_idx = find_orig_col(headers, nm); + if (col_idx < 0) { + int di = find_derived_idx(state.raw().derived, nm); + if (di >= 0) col_idx = orig_cols + di; + } + if (col_idx < 0 || col_idx >= eff_cols) { lua_pop(L, 1); continue; } + seen[col_idx] = true; + + // visible + lua_getfield(L, -1, "visible"); + if (lua_isboolean(L, -1)) state.col_visible[col_idx] = lua_toboolean(L, -1); + lua_pop(L, 1); + + // order + lua_getfield(L, -1, "order"); + int order_val = lua_isnumber(L, -1) ? (int)lua_tointeger(L, -1) : (col_idx + 1); + lua_pop(L, 1); + order_pairs.emplace_back(order_val, col_idx); + + // type (mutable solo para derived) + lua_getfield(L, -1, "type"); + if (lua_isstring(L, -1)) { + std::string tn = lua_tostring(L, -1); + ColumnType t = column_type_from_string(tn); + if (col_idx >= orig_cols) { + state.raw().derived[col_idx - orig_cols].type = t; + } + } + lua_pop(L, 1); + + // color_rules + lua_getfield(L, -1, "color_rules"); + if (lua_istable(L, -1)) { + int rn = (int)lua_rawlen(L, -1); + for (int j = 1; j <= rn; ++j) { + lua_rawgeti(L, -1, j); + if (lua_istable(L, -1)) { + lua_getfield(L, -1, "equals"); + std::string eq = lua_to_string(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "color"); + std::string hx = lua_to_string(L, -1); + lua_pop(L, 1); + state.color_rules.push_back({col_idx, eq, hex_to_color(hx)}); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + lua_pop(L, 1); // pop entry + } + + std::sort(order_pairs.begin(), order_pairs.end()); + state.col_order.clear(); + for (auto& p : order_pairs) state.col_order.push_back(p.second); + for (int c = 0; c < eff_cols; ++c) if (!seen[c]) state.col_order.push_back(c); + } + lua_pop(L, 1); // columns + + // ---- Walk views[] (extra viz panels) ---- + state.extra_panels.clear(); + lua_getfield(L, -1, "views"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + VizPanel p; + lua_getfield(L, -1, "display"); + if (lua_isstring(L, -1)) p.display = view_mode_from_token(lua_tostring(L, -1)); + lua_pop(L, 1); + + auto read_str = [&](const char* key, std::string& out_s) { + lua_getfield(L, -1, key); + if (lua_isstring(L, -1)) out_s = lua_tostring(L, -1); + lua_pop(L, 1); + }; + read_str("x_col", p.config.x_col); + read_str("cat_col", p.config.cat_col); + read_str("size_col", p.config.size_col); + + lua_getfield(L, -1, "y_cols"); + if (lua_istable(L, -1)) { + int yn = (int)lua_rawlen(L, -1); + for (int j = 1; j <= yn; ++j) { + lua_rawgeti(L, -1, j); + if (lua_isstring(L, -1)) p.config.y_cols.emplace_back(lua_tostring(L, -1)); + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + lua_getfield(L, -1, "color"); + if (lua_isstring(L, -1)) p.config.primary_color = hex_to_color(lua_tostring(L, -1)); + lua_pop(L, 1); + + lua_getfield(L, -1, "hist_bins"); + if (lua_isnumber(L, -1)) p.config.hist_bins = (int)lua_tointeger(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "pie_radius"); + if (lua_isnumber(L, -1)) p.config.pie_radius = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "show_legend"); + if (lua_isboolean(L, -1)) p.config.show_legend = lua_toboolean(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "show_markers"); + if (lua_isboolean(L, -1)) p.config.show_markers = lua_toboolean(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "locked"); + if (lua_isboolean(L, -1)) p.config.locked = lua_toboolean(L, -1); + lua_pop(L, 1); + + // Panel 0 = main viz (state.display + state.viz_config). + if (i == 1) { + state.display = p.display; + state.viz_config = p.config; + } else { + state.extra_panels.push_back(p); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // views + + lua_pop(L, 1); // pop root + return finish_with_warns(true); +} + +} // namespace tql diff --git a/playground/tables/tql.h b/playground/tables/tql.h new file mode 100644 index 0000000..b06eec7 --- /dev/null +++ b/playground/tables/tql.h @@ -0,0 +1,42 @@ +// TQL — Table Query Language emit/apply. Round-trip entre State y Lua text. +// Ver docs/TQL.md. +#pragma once + +#include "data_table_logic.h" +#include +#include + +namespace tql { + +// Serializa el estado actual a un Lua chunk completo: +// return { version, display, stages, columns, visualization_settings } +// +// `headers` y `types` describen las cols originales (size = orig_cols). +// Las derived cols se anaden automaticamente desde state.derived. +std::string emit(const data_table::State& state, + const std::vector& headers, + const std::vector& types); + +// Parsea un Lua chunk TQL y rellena State. Mutates: +// - stages (clears + reconstruye desde stages[] del TQL; stage 0 = Raw con +// filters/expressions/sort; stages 1+ con filter/breakout/aggregation/sort) +// - col_visible / col_order (desde columns[]) +// - color_rules (desde columns[].color_rules) +// - stages[0].derived[].type (desde columns[].type para nombres derived) +// +// `cells/rows/orig_cols` necesarios para sample auto-detect de tipos en +// expressions (cuando la entry columns omite el type explicito). +bool apply(const std::string& lua_text, + data_table::State& state, + const std::vector& headers, + const std::vector& types, + const char* const* cells, int rows, int orig_cols, + std::string* err); + +// Helpers expuestos para tests. +std::string lua_string_literal(const std::string& s); +std::string color_to_hex(unsigned int c); +unsigned int hex_to_color(const std::string& s); +data_table::ColumnType column_type_from_string(const std::string& s); + +} // namespace tql diff --git a/playground/tables/viz.cpp b/playground/tables/viz.cpp new file mode 100644 index 0000000..2f71704 --- /dev/null +++ b/playground/tables/viz.cpp @@ -0,0 +1,800 @@ +#include "viz.h" +#include "implot.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace viz { + +using data_table::StageOutput; +using data_table::ColumnType; +using data_table::ViewMode; +using data_table::ViewConfig; +using data_table::parse_number; + +static int find_header(const StageOutput& out, const std::string& name) { + if (name.empty()) return -1; + for (size_t c = 0; c < out.headers.size(); ++c) + if (out.headers[c] == name) return (int)c; + return -1; +} + +static int resolve_x(const StageOutput& out, const ViewConfig& cfg, int fallback) { + int c = find_header(out, cfg.x_col); + return (c >= 0) ? c : fallback; +} +static int resolve_cat(const StageOutput& out, const ViewConfig& cfg, int fallback) { + int c = find_header(out, cfg.cat_col); + return (c >= 0) ? c : fallback; +} +static int resolve_size(const StageOutput& out, const ViewConfig& cfg, int fallback) { + int c = find_header(out, cfg.size_col); + return (c >= 0) ? c : fallback; +} + +int first_numeric_col(const StageOutput& out) { + for (size_t c = 0; c < out.types.size(); ++c) { + if (out.types[c] == ColumnType::Int || out.types[c] == ColumnType::Float) return (int)c; + } + return -1; +} + +int first_category_col(const StageOutput& out) { + for (size_t c = 0; c < out.types.size(); ++c) { + ColumnType t = out.types[c]; + if (t == ColumnType::String || t == ColumnType::Date || t == ColumnType::Bool || + t == ColumnType::Json) return (int)c; + } + return -1; +} + +std::vector extract_numeric(const StageOutput& out, int col) { + std::vector v; + if (col < 0 || col >= out.cols) return v; + v.reserve(out.rows); + for (int r = 0; r < out.rows; ++r) { + const char* s = out.cells[(size_t)r * out.cols + col]; + double d = 0; + if (s && *s && parse_number(s, d)) v.push_back(d); + else v.push_back(std::nan("")); + } + return v; +} + +std::vector extract_category(const StageOutput& out, int col) { + std::vector v; + if (col < 0 || col >= out.cols) return v; + v.reserve(out.rows); + for (int r = 0; r < out.rows; ++r) { + const char* s = out.cells[(size_t)r * out.cols + col]; + v.emplace_back(s ? s : ""); + } + return v; +} + +namespace { + +struct NumCol { int idx; std::string name; std::vector vals; }; + +std::vector collect_numeric(const StageOutput& out, int max_n = 16) { + std::vector r; + for (size_t c = 0; c < out.types.size() && (int)r.size() < max_n; ++c) { + if (out.types[c] == ColumnType::Int || out.types[c] == ColumnType::Float) { + NumCol nc; + nc.idx = (int)c; + nc.name = out.headers[c]; + nc.vals = extract_numeric(out, (int)c); + r.push_back(std::move(nc)); + } + } + return r; +} + +std::vector collect_numeric_filtered(const StageOutput& out, + const ViewConfig& cfg, + int max_n = 16) { + if (cfg.y_cols.empty()) return collect_numeric(out, max_n); + std::vector r; + for (const auto& name : cfg.y_cols) { + if ((int)r.size() >= max_n) break; + int c = find_header(out, name); + if (c < 0) continue; + if (out.types[c] != ColumnType::Int && out.types[c] != ColumnType::Float) continue; + NumCol nc; + nc.idx = c; + nc.name = out.headers[c]; + nc.vals = extract_numeric(out, c); + r.push_back(std::move(nc)); + } + if (r.empty()) return collect_numeric(out, max_n); + return r; +} + +ImPlotSpec spec_with_color(unsigned int rgba_color) { + if (rgba_color == 0) return ImPlotSpec(); + ImU32 c = (ImU32)rgba_color; + return ImPlotSpec(ImPlotProp_LineColor, c, ImPlotProp_FillColor, c); +} + +// Axis flags: locked = no pan/zoom; unlocked = 0 (sin AutoFit, para preservar +// pan/zoom del user). Re-fit explicito via SetNextAxesToFit cuando fit_request. +ImPlotAxisFlags axflag(const ViewConfig& cfg, ImPlotAxisFlags base = 0) { + if (cfg.locked) return base | ImPlotAxisFlags_Lock; + return base; +} + +// Llamar antes de BeginPlot. Si cfg.fit_request -> fuerza re-fit y limpia el flag. +void maybe_fit(const ViewConfig& cfg) { + if (cfg.fit_request) { + ImPlot::SetNextAxesToFit(); + cfg.fit_request = false; + } +} + +void info_text(const char* msg) { + ImVec2 avail = ImGui::GetContentRegionAvail(); + ImVec2 sz = ImGui::CalcTextSize(msg); + ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPosX() + (avail.x - sz.x) * 0.5f, + ImGui::GetCursorPosY() + (avail.y - sz.y) * 0.5f)); + ImGui::TextDisabled("%s", msg); +} + +// Drop NaN and pair with optional labels. +std::vector finite(const std::vector& v) { + std::vector r; r.reserve(v.size()); + for (double d : v) if (!std::isnan(d)) r.push_back(d); + return r; +} + +bool render_bar_like(const StageOutput& out, ViewMode mode, + const ViewConfig& cfg, ImVec2 size) { + int cat_col = resolve_cat(out, cfg, first_category_col(out)); + auto nums = collect_numeric_filtered(out, cfg, 8); + if (cat_col < 0 || nums.empty()) { + info_text("Need 1 category + 1+ numeric columns"); + return false; + } + auto cats = extract_category(out, cat_col); + int n = (int)cats.size(); + if (n == 0) { info_text("Empty data"); return false; } + + // Ticks + std::vector ticks(n); + std::vector labels(n); + for (int i = 0; i < n; ++i) { ticks[i] = i; labels[i] = cats[i].c_str(); } + + bool horiz = (mode == ViewMode::Bar); + ImPlotFlags pflags = cfg.show_legend ? 0 : ImPlotFlags_NoLegend; + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##bar", size, pflags)) return false; + + ImPlotAxisFlags ax_cat = axflag(cfg); + ImPlotAxisFlags ax_num = axflag(cfg); + + if (horiz) { + ImPlot::SetupAxes(out.headers[nums[0].idx].c_str(), out.headers[cat_col].c_str(), + ax_num, ax_cat); + ImPlot::SetupAxisTicks(ImAxis_Y1, ticks.data(), n, labels.data(), false); + } else { + ImPlot::SetupAxes(out.headers[cat_col].c_str(), out.headers[nums[0].idx].c_str(), + ax_cat, ax_num); + ImPlot::SetupAxisTicks(ImAxis_X1, ticks.data(), n, labels.data(), false); + } + + if (mode == ViewMode::StackedBar || mode == ViewMode::GroupedBar) { + // Build flat matrix items x groups + int items = (int)nums.size(); + std::vector mat((size_t)items * n, 0.0); + std::vector series_labels(items); + for (int it = 0; it < items; ++it) { + series_labels[it] = nums[it].name.c_str(); + for (int g = 0; g < n; ++g) { + double d = nums[it].vals[g]; + mat[(size_t)it * n + g] = std::isnan(d) ? 0.0 : d; + } + } + int flags = (mode == ViewMode::StackedBar) ? ImPlotBarGroupsFlags_Stacked : 0; + if (horiz) flags |= ImPlotBarGroupsFlags_Horizontal; + ImPlot::PlotBarGroups(series_labels.data(), mat.data(), items, n, 0.67, 0, + ImPlotSpec(ImPlotProp_Flags, flags)); + } else { + // Single series (first numeric col). + std::vector ys(n); + for (int i = 0; i < n; ++i) { + double d = nums[0].vals[i]; + ys[i] = std::isnan(d) ? 0.0 : d; + } + ImPlotSpec spc = spec_with_color(cfg.primary_color); + if (horiz) { + if (cfg.primary_color != 0) { + ImU32 col = (ImU32)cfg.primary_color; + ImPlot::PlotBars(nums[0].name.c_str(), ys.data(), ticks.data(), n, 0.67, + ImPlotSpec(ImPlotProp_Flags, ImPlotBarsFlags_Horizontal, + ImPlotProp_FillColor, col, + ImPlotProp_LineColor, col)); + } else { + ImPlot::PlotBars(nums[0].name.c_str(), ys.data(), ticks.data(), n, 0.67, + ImPlotSpec(ImPlotProp_Flags, ImPlotBarsFlags_Horizontal)); + } + } else { + ImPlot::PlotBars(nums[0].name.c_str(), ticks.data(), ys.data(), n, 0.67, spc); + } + } + ImPlot::EndPlot(); + return true; +} + +bool render_line_like(const StageOutput& out, ViewMode mode, + const ViewConfig& cfg, ImVec2 size) { + auto nums = collect_numeric_filtered(out, cfg, 8); + if (nums.empty()) { info_text("Need at least 1 numeric column"); return false; } + + ImPlotFlags pflags = cfg.show_legend ? 0 : ImPlotFlags_NoLegend; + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##line", size, pflags)) return false; + ImPlot::SetupAxes(nullptr, nullptr, axflag(cfg), axflag(cfg)); + + int n = nums.empty() ? 0 : (int)nums[0].vals.size(); + if (n == 0) { ImPlot::EndPlot(); return false; } + + // X column: cfg.x_col override; sino primer numeric si hay >=2; sino indices. + int x_idx = -1; + if (!cfg.x_col.empty()) { + int xc = find_header(out, cfg.x_col); + if (xc >= 0 && (out.types[xc] == ColumnType::Int || out.types[xc] == ColumnType::Float)) { + x_idx = xc; + } + } + std::vector idx_xs; + const double* xs = nullptr; + int start_y = 0; + std::vector x_data_external; + if (x_idx >= 0) { + x_data_external = extract_numeric(out, x_idx); + xs = x_data_external.data(); + } else if (nums.size() >= 2 && cfg.y_cols.empty()) { + xs = nums[0].vals.data(); + start_y = 1; + } else { + idx_xs.resize(n); + for (int i = 0; i < n; ++i) idx_xs[i] = i; + xs = idx_xs.data(); + } + + bool only_one = (cfg.primary_color != 0) && (nums.size() - start_y == 1); + for (size_t i = (size_t)start_y; i < nums.size(); ++i) { + const auto& nc = nums[i]; + ImU32 col = only_one ? (ImU32)cfg.primary_color : 0; + int marker = cfg.show_markers ? ImPlotMarker_Circle : ImPlotMarker_None; + if (mode == ViewMode::Area) { + if (col) { + ImPlot::PlotShaded(nc.name.c_str(), xs, nc.vals.data(), (int)nc.vals.size(), 0.0, + ImPlotSpec(ImPlotProp_FillColor, col, ImPlotProp_LineColor, col)); + } else { + ImPlot::PlotShaded(nc.name.c_str(), xs, nc.vals.data(), (int)nc.vals.size(), 0.0); + } + } else if (mode == ViewMode::Stairs) { + if (col) { + ImPlot::PlotStairs(nc.name.c_str(), xs, nc.vals.data(), (int)nc.vals.size(), + ImPlotSpec(ImPlotProp_LineColor, col)); + } else { + ImPlot::PlotStairs(nc.name.c_str(), xs, nc.vals.data(), (int)nc.vals.size()); + } + } else { + if (col) { + ImPlot::PlotLine(nc.name.c_str(), xs, nc.vals.data(), (int)nc.vals.size(), + ImPlotSpec(ImPlotProp_Flags, ImPlotLineFlags_SkipNaN, + ImPlotProp_LineColor, col, + ImPlotProp_Marker, marker)); + } else { + ImPlot::PlotLine(nc.name.c_str(), xs, nc.vals.data(), (int)nc.vals.size(), + ImPlotSpec(ImPlotProp_Flags, ImPlotLineFlags_SkipNaN, + ImPlotProp_Marker, marker)); + } + } + } + ImPlot::EndPlot(); + return true; +} + +bool render_scatter(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + // Soporte cfg.x_col + cfg.y_cols[0] + int xc = find_header(out, cfg.x_col); + int yc = !cfg.y_cols.empty() ? find_header(out, cfg.y_cols[0]) : -1; + std::vector nums; + if (xc >= 0 && yc >= 0) { + NumCol a{xc, out.headers[xc], extract_numeric(out, xc)}; + NumCol b{yc, out.headers[yc], extract_numeric(out, yc)}; + nums = {a, b}; + } else { + nums = collect_numeric(out, 4); + } + if (nums.size() < 2) { info_text("Need 2 numeric columns"); return false; } + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##scatter", size, cfg.show_legend ? 0 : ImPlotFlags_NoLegend)) return false; + ImPlot::SetupAxes(nums[0].name.c_str(), nums[1].name.c_str(), + axflag(cfg), axflag(cfg)); + if (cfg.primary_color) { + ImU32 col = (ImU32)cfg.primary_color; + ImPlot::PlotScatter("##s", nums[0].vals.data(), nums[1].vals.data(), + (int)nums[0].vals.size(), + ImPlotSpec(ImPlotProp_MarkerFillColor, col, + ImPlotProp_MarkerLineColor, col)); + } else { + ImPlot::PlotScatter("##s", nums[0].vals.data(), nums[1].vals.data(), + (int)nums[0].vals.size()); + } + ImPlot::EndPlot(); + return true; +} + +bool render_bubble(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + int xc = find_header(out, cfg.x_col); + int yc = !cfg.y_cols.empty() ? find_header(out, cfg.y_cols[0]) : -1; + int sc = resolve_size(out, cfg, -1); + std::vector nums; + if (xc >= 0 && yc >= 0 && sc >= 0) { + nums = { + {xc, out.headers[xc], extract_numeric(out, xc)}, + {yc, out.headers[yc], extract_numeric(out, yc)}, + {sc, out.headers[sc], extract_numeric(out, sc)}, + }; + } else { + nums = collect_numeric(out, 4); + } + if (nums.size() < 3) { info_text("Need 3 numeric columns (x, y, size)"); return false; } + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##bubble", size, cfg.show_legend ? 0 : ImPlotFlags_NoLegend)) return false; + ImPlot::SetupAxes(nums[0].name.c_str(), nums[1].name.c_str(), + axflag(cfg), axflag(cfg)); + ImPlot::PlotBubbles("##b", nums[0].vals.data(), nums[1].vals.data(), + nums[2].vals.data(), (int)nums[0].vals.size()); + ImPlot::EndPlot(); + return true; +} + +bool render_histogram(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + auto nums = collect_numeric_filtered(out, cfg, 4); + if (nums.empty()) { info_text("Need 1 numeric column"); return false; } + auto vals = finite(nums[0].vals); + if (vals.empty()) { info_text("No finite values"); return false; } + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##hist", size, cfg.show_legend ? 0 : ImPlotFlags_NoLegend)) return false; + ImPlot::SetupAxes(nums[0].name.c_str(), "count", + axflag(cfg), axflag(cfg)); + int bins = (cfg.hist_bins > 0) ? cfg.hist_bins : ImPlotBin_Sturges; + if (cfg.primary_color) { + ImU32 col = (ImU32)cfg.primary_color; + ImPlot::PlotHistogram("##h", vals.data(), (int)vals.size(), bins, 1.0, + ImPlotRange(), + ImPlotSpec(ImPlotProp_FillColor, col, + ImPlotProp_LineColor, col)); + } else { + ImPlot::PlotHistogram("##h", vals.data(), (int)vals.size(), bins); + } + ImPlot::EndPlot(); + return true; +} + +bool render_hist2d(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + int xc = find_header(out, cfg.x_col); + int yc = !cfg.y_cols.empty() ? find_header(out, cfg.y_cols[0]) : -1; + std::vector nums; + if (xc >= 0 && yc >= 0) { + nums = { + {xc, out.headers[xc], extract_numeric(out, xc)}, + {yc, out.headers[yc], extract_numeric(out, yc)}, + }; + } else { + nums = collect_numeric(out, 2); + } + if (nums.size() < 2) { info_text("Need 2 numeric columns"); return false; } + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##hist2d", size, cfg.show_legend ? 0 : ImPlotFlags_NoLegend)) return false; + ImPlot::SetupAxes(nums[0].name.c_str(), nums[1].name.c_str()); + int bins = (cfg.hist_bins > 0) ? cfg.hist_bins : ImPlotBin_Sturges; + ImPlot::PlotHistogram2D("##h2", nums[0].vals.data(), nums[1].vals.data(), + (int)nums[0].vals.size(), bins, bins); + ImPlot::EndPlot(); + return true; +} + +bool render_heatmap(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + auto nums = collect_numeric_filtered(out, cfg, 64); + if (nums.empty()) { info_text("Need numeric columns"); return false; } + int cols = (int)nums.size(); + int rows = (int)nums[0].vals.size(); + if (rows == 0) { info_text("No rows"); return false; } + std::vector mat((size_t)rows * cols, 0.0); + double mn = +1e300, mx = -1e300; + for (int c = 0; c < cols; ++c) { + for (int r = 0; r < rows; ++r) { + double d = nums[c].vals[r]; + if (std::isnan(d)) d = 0; + mat[(size_t)r * cols + c] = d; + if (d < mn) mn = d; if (d > mx) mx = d; + } + } + if (mn == mx) { mx = mn + 1; } + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##heatmap", size, 0)) return false; + ImPlot::PlotHeatmap("##hm", mat.data(), rows, cols, mn, mx, nullptr); + ImPlot::EndPlot(); + return true; +} + +bool render_pie(const StageOutput& out, const ViewConfig& cfg, bool donut, ImVec2 size) { + int cat = resolve_cat(out, cfg, first_category_col(out)); + auto nums = collect_numeric_filtered(out, cfg, 1); + if (cat < 0 || nums.empty()) { info_text("Need 1 category + 1 numeric"); return false; } + auto cats = extract_category(out, cat); + int n = std::min((int)cats.size(), (int)nums[0].vals.size()); + if (n == 0) return false; + std::vector values(n); + std::vector labels(n); + for (int i = 0; i < n; ++i) { + double d = nums[0].vals[i]; + values[i] = std::isnan(d) ? 0.0 : std::abs(d); + labels[i] = cats[i].c_str(); + } + ImPlotFlags pf = ImPlotFlags_Equal; + if (!cfg.show_legend) pf |= ImPlotFlags_NoLegend; + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##pie", size, pf)) return false; + ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoDecorations, + ImPlotAxisFlags_NoDecorations); + ImPlot::SetupAxesLimits(0, 1, 0, 1, ImPlotCond_Always); + double radius = (cfg.pie_radius > 0) ? cfg.pie_radius : (donut ? 0.4 : 0.45); + ImPlot::PlotPieChart(labels.data(), values.data(), n, 0.5, 0.5, radius, "%.1f"); + if (donut) { + // Draw inner hole as solid circle by overlaying a smaller pie of one slice transparent. + // Simpler: just visually it's a circle with text. Use no extra primitive for now. + } + ImPlot::EndPlot(); + return true; +} + +bool render_funnel(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + int cat = resolve_cat(out, cfg, first_category_col(out)); + auto nums = collect_numeric_filtered(out, cfg, 1); + if (cat < 0 || nums.empty()) { info_text("Need 1 category + 1 numeric"); return false; } + auto cats = extract_category(out, cat); + int n = std::min((int)cats.size(), (int)nums[0].vals.size()); + if (n == 0) return false; + // Sort desc by value + std::vector idx(n); + for (int i = 0; i < n; ++i) idx[i] = i; + std::sort(idx.begin(), idx.end(), [&](int a, int b) { + double da = std::isnan(nums[0].vals[a]) ? -1e300 : nums[0].vals[a]; + double db = std::isnan(nums[0].vals[b]) ? -1e300 : nums[0].vals[b]; + return da > db; + }); + std::vector ys(n); + std::vector ticks(n); + std::vector labels(n); + std::vector labels_store(n); + for (int i = 0; i < n; ++i) { + double d = nums[0].vals[idx[i]]; + ys[i] = std::isnan(d) ? 0 : d; + ticks[i] = n - 1 - i; // descending order + labels_store[i] = cats[idx[i]]; + labels[i] = labels_store[i].c_str(); + } + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##funnel", size, 0)) return false; + ImPlot::SetupAxes(nums[0].name.c_str(), out.headers[cat].c_str(), + axflag(cfg), axflag(cfg)); + ImPlot::SetupAxisTicks(ImAxis_Y1, ticks.data(), n, labels.data(), false); + ImPlot::PlotBars(nums[0].name.c_str(), ys.data(), ticks.data(), n, 0.85, + ImPlotSpec(ImPlotProp_Flags, ImPlotBarsFlags_Horizontal)); + ImPlot::EndPlot(); + return true; +} + +bool render_waterfall(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + auto nums = collect_numeric_filtered(out, cfg, 1); + if (nums.empty()) { info_text("Need 1 numeric column"); return false; } + int n = (int)nums[0].vals.size(); + if (n == 0) return false; + int cat = resolve_cat(out, cfg, first_category_col(out)); + auto cats = (cat >= 0) ? extract_category(out, cat) : std::vector(); + + std::vector running(n + 1, 0); + for (int i = 0; i < n; ++i) { + double d = std::isnan(nums[0].vals[i]) ? 0 : nums[0].vals[i]; + running[i + 1] = running[i] + d; + } + std::vector ticks(n); + for (int i = 0; i < n; ++i) ticks[i] = i; + std::vector labels(n); + for (int i = 0; i < n; ++i) labels[i] = (i < (int)cats.size()) ? cats[i].c_str() : ""; + + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##waterfall", size, 0)) return false; + ImPlot::SetupAxes(nullptr, nums[0].name.c_str(), + axflag(cfg), axflag(cfg)); + if (cat >= 0) ImPlot::SetupAxisTicks(ImAxis_X1, ticks.data(), n, labels.data(), false); + // Draw stems with rectangles via error-bars trick: low=cum_prev, high=cum_curr. + std::vector mid(n), err(n); + for (int i = 0; i < n; ++i) { + mid[i] = (running[i] + running[i + 1]) * 0.5; + err[i] = std::abs((running[i + 1] - running[i]) * 0.5); + } + ImPlot::PlotErrorBars("##wf", ticks.data(), mid.data(), err.data(), n); + ImPlot::PlotLine("cum", running.data() + 1, n); + ImPlot::EndPlot(); + return true; +} + +bool render_kpi_single(const StageOutput& out, const ViewConfig& cfg) { + int nc = !cfg.y_cols.empty() ? find_header(out, cfg.y_cols[0]) : -1; + if (nc < 0) nc = first_numeric_col(out); + if (nc < 0) { info_text("Need 1 numeric column"); return false; } + auto vals = extract_numeric(out, nc); + if (vals.empty()) { info_text("Empty"); return false; } + double last = std::nan(""); + for (auto v : vals) if (!std::isnan(v)) last = v; + if (std::isnan(last)) { info_text("No finite values"); return false; } + + char buf[64]; + if (std::abs(last) >= 1e6) std::snprintf(buf, sizeof(buf), "%.2fM", last / 1e6); + else if (std::abs(last) >= 1e3) std::snprintf(buf, sizeof(buf), "%.2fK", last / 1e3); + else std::snprintf(buf, sizeof(buf), "%.3g", last); + + ImVec2 avail = ImGui::GetContentRegionAvail(); + ImGui::SetWindowFontScale(4.0f); + ImVec2 sz = ImGui::CalcTextSize(buf); + ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPosX() + (avail.x - sz.x) * 0.5f, + ImGui::GetCursorPosY() + (avail.y - sz.y) * 0.5f - 20)); + ImGui::TextUnformatted(buf); + ImGui::SetWindowFontScale(1.0f); + + sz = ImGui::CalcTextSize(out.headers[nc].c_str()); + ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPosX() + (avail.x - sz.x) * 0.5f, + ImGui::GetCursorPosY() + (avail.y - sz.y) * 0.5f - 10)); + ImGui::TextDisabled("%s", out.headers[nc].c_str()); + return true; +} + +bool render_kpi_grid(const StageOutput& out, const ViewConfig& cfg) { + auto nums = collect_numeric_filtered(out, cfg, 12); + if (nums.empty()) { info_text("Need numeric columns"); return false; } + ImVec2 avail = ImGui::GetContentRegionAvail(); + int per_row = std::max(1, (int)(avail.x / 220)); + int idx = 0; + for (auto& nc : nums) { + double last = std::nan(""); + for (auto v : nc.vals) if (!std::isnan(v)) last = v; + if (std::isnan(last)) last = 0; + char buf[64]; + if (std::abs(last) >= 1e6) std::snprintf(buf, sizeof(buf), "%.2fM", last / 1e6); + else if (std::abs(last) >= 1e3) std::snprintf(buf, sizeof(buf), "%.2fK", last / 1e3); + else std::snprintf(buf, sizeof(buf), "%.4g", last); + + ImGui::BeginChild((ImGuiID)(0x1000 + idx), ImVec2(210, 100), true); + ImGui::TextDisabled("%s", nc.name.c_str()); + ImGui::SetWindowFontScale(2.4f); + ImGui::TextUnformatted(buf); + ImGui::SetWindowFontScale(1.0f); + ImGui::EndChild(); + + if ((idx % per_row) != (per_row - 1)) ImGui::SameLine(); + idx++; + } + return true; +} + +bool render_stem(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + auto nums = collect_numeric_filtered(out, cfg, 1); + if (nums.empty()) { info_text("Need 1 numeric column"); return false; } + int n = (int)nums[0].vals.size(); + std::vector xs(n); for (int i = 0; i < n; ++i) xs[i] = i; + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##stem", size, 0)) return false; + ImPlot::SetupAxes(nullptr, nums[0].name.c_str(), + axflag(cfg), axflag(cfg)); + ImPlot::PlotStems(nums[0].name.c_str(), xs.data(), nums[0].vals.data(), n); + ImPlot::EndPlot(); + return true; +} + +bool render_errorbars(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + auto nums = collect_numeric_filtered(out, cfg, 4); + if (nums.size() < 2) { info_text("Need 2 numeric columns (value, err)"); return false; } + int n = (int)nums[0].vals.size(); + std::vector xs(n); for (int i = 0; i < n; ++i) xs[i] = i; + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##eb", size, 0)) return false; + ImPlot::SetupAxes(nullptr, nums[0].name.c_str(), + axflag(cfg), axflag(cfg)); + ImPlot::PlotErrorBars(nums[0].name.c_str(), xs.data(), + nums[0].vals.data(), nums[1].vals.data(), n); + ImPlot::PlotScatter("##s", xs.data(), nums[0].vals.data(), n); + ImPlot::EndPlot(); + return true; +} + +// BoxPlot: agrupar por categoria, calcular min/p25/p50/p75/max y dibujar +// rectangulos manuales via PlotShaded + lineas. +bool render_boxplot(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + int cat = resolve_cat(out, cfg, first_category_col(out)); + auto nums = collect_numeric_filtered(out, cfg, 1); + if (cat < 0 || nums.empty()) { info_text("Need 1 category + 1 numeric"); return false; } + auto cats = extract_category(out, cat); + int n = std::min((int)cats.size(), (int)nums[0].vals.size()); + if (n == 0) return false; + + // Group values by category + std::unordered_map> groups; + std::vector order; + for (int i = 0; i < n; ++i) { + if (groups.find(cats[i]) == groups.end()) order.push_back(cats[i]); + double d = nums[0].vals[i]; + if (!std::isnan(d)) groups[cats[i]].push_back(d); + } + int G = (int)order.size(); + if (G == 0) return false; + + std::vector mn(G), p25(G), p50(G), p75(G), mx(G), xs(G); + std::vector labels(G); + for (int g = 0; g < G; ++g) { + auto& v = groups[order[g]]; + std::sort(v.begin(), v.end()); + int N = (int)v.size(); + xs[g] = g; + labels[g]= order[g].c_str(); + if (N == 0) { mn[g]=p25[g]=p50[g]=p75[g]=mx[g]=0; continue; } + mn[g] = v.front(); + mx[g] = v.back(); + p25[g] = v[std::min(N - 1, (int)(N * 0.25))]; + p50[g] = v[std::min(N - 1, (int)(N * 0.50))]; + p75[g] = v[std::min(N - 1, (int)(N * 0.75))]; + } + + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##box", size, 0)) return false; + ImPlot::SetupAxes(out.headers[cat].c_str(), nums[0].name.c_str(), + axflag(cfg), axflag(cfg)); + ImPlot::SetupAxisTicks(ImAxis_X1, xs.data(), G, labels.data(), false); + + // Whiskers: stems from min to max + for (int g = 0; g < G; ++g) { + double lo[2] = { mn[g], mx[g] }; + double xx[2] = { xs[g], xs[g] }; + ImPlot::PlotLine("##wh", xx, lo, 2); + } + // Box: p25..p75 as bars centered on p50 + std::vector mid(G), half(G); + for (int g = 0; g < G; ++g) { + mid[g] = (p25[g] + p75[g]) * 0.5; + half[g] = (p75[g] - p25[g]) * 0.5; + } + ImPlot::PlotErrorBars("box", xs.data(), mid.data(), half.data(), G); + ImPlot::PlotScatter("median", xs.data(), p50.data(), G); + ImPlot::EndPlot(); + return true; +} + +// Candlestick: tiempo + O/H/L/C. Asume 4 primeras cols numericas en ese orden. +bool render_candlestick(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + auto nums = collect_numeric_filtered(out, cfg, 8); + if (nums.size() < 4) { info_text("Need 4 numeric columns: O/H/L/C"); return false; } + int n = (int)nums[0].vals.size(); + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##candle", size, 0)) return false; + ImPlot::SetupAxes("t", "price", axflag(cfg), axflag(cfg)); + std::vector xs(n); for (int i = 0; i < n; ++i) xs[i] = i; + const auto& O = nums[0].vals; + const auto& H = nums[1].vals; + const auto& L = nums[2].vals; + const auto& C = nums[3].vals; + // Wicks + for (int i = 0; i < n; ++i) { + double xx[2] = { xs[i], xs[i] }; + double yy[2] = { L[i], H[i] }; + ImPlot::PlotLine("##wick", xx, yy, 2); + } + // Body via PlotBars(mid, |C-O|)? Simpler: separate lines. + std::vector body_low(n), body_high(n), body_mid(n), body_err(n); + for (int i = 0; i < n; ++i) { + body_low[i] = std::min(O[i], C[i]); + body_high[i] = std::max(O[i], C[i]); + body_mid[i] = (body_low[i] + body_high[i]) * 0.5; + body_err[i] = (body_high[i] - body_low[i]) * 0.5; + } + ImPlot::PlotErrorBars("OHLC", xs.data(), body_mid.data(), body_err.data(), n); + ImPlot::EndPlot(); + return true; +} + +bool render_radar(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) { + auto nums = collect_numeric_filtered(out, cfg, 12); + if (nums.size() < 3) { info_text("Need 3+ numeric columns"); return false; } + int K = (int)nums.size(); + int n = (int)nums[0].vals.size(); + if (n == 0) return false; + // Take first row as the polygon. + std::vector xs(K + 1), ys(K + 1); + double radius_norm = 0; + for (int k = 0; k < K; ++k) { + double d = nums[k].vals[0]; + if (std::isnan(d)) d = 0; + radius_norm = std::max(radius_norm, std::abs(d)); + } + if (radius_norm == 0) radius_norm = 1; + for (int k = 0; k < K; ++k) { + double v = nums[k].vals[0]; if (std::isnan(v)) v = 0; + double angle = 2 * 3.14159265358979 * k / K - 3.14159265358979 / 2; + double r = v / radius_norm; + xs[k] = std::cos(angle) * r; + ys[k] = std::sin(angle) * r; + } + xs[K] = xs[0]; ys[K] = ys[0]; + maybe_fit(cfg); + if (!ImPlot::BeginPlot("##radar", size, + ImPlotFlags_Equal | ImPlotFlags_NoLegend)) return false; + ImPlot::SetupAxesLimits(-1.2, 1.2, -1.2, 1.2, ImPlotCond_Always); + ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoDecorations, + ImPlotAxisFlags_NoDecorations); + // Grid rings + for (double rr : {0.25, 0.5, 0.75, 1.0}) { + double gx[64], gy[64]; + for (int i = 0; i < 64; ++i) { + double a = 2 * 3.14159265358979 * i / 63; + gx[i] = std::cos(a) * rr; gy[i] = std::sin(a) * rr; + } + ImPlot::PlotLine("##grid", gx, gy, 64); + } + ImPlot::PlotLine("radar", xs.data(), ys.data(), K + 1); + // Axis labels + for (int k = 0; k < K; ++k) { + double a = 2 * 3.14159265358979 * k / K - 3.14159265358979 / 2; + ImPlot::PlotText(nums[k].name.c_str(), std::cos(a) * 1.1, std::sin(a) * 1.1); + } + ImPlot::EndPlot(); + return true; +} + +} // anon + +bool render(const StageOutput& out, ViewMode mode, + const ViewConfig& cfg, ImVec2 size) { + if (out.rows == 0 || out.cols == 0) { + info_text("No data"); + return false; + } + switch (mode) { + case ViewMode::Table: return false; + case ViewMode::Bar: + case ViewMode::Column: + case ViewMode::GroupedBar: + case ViewMode::StackedBar: return render_bar_like(out, mode, cfg, size); + case ViewMode::Line: + case ViewMode::Area: + case ViewMode::Stairs: return render_line_like(out, mode, cfg, size); + case ViewMode::Scatter: return render_scatter(out, cfg, size); + case ViewMode::Bubble: return render_bubble(out, cfg, size); + case ViewMode::Histogram: return render_histogram(out, cfg, size); + case ViewMode::Histogram2D: return render_hist2d(out, cfg, size); + case ViewMode::Heatmap: return render_heatmap(out, cfg, size); + case ViewMode::BoxPlot: return render_boxplot(out, cfg, size); + case ViewMode::Stem: return render_stem(out, cfg, size); + case ViewMode::ErrorBars: return render_errorbars(out, cfg, size); + case ViewMode::Pie: return render_pie(out, cfg, false, size); + case ViewMode::Donut: return render_pie(out, cfg, true, size); + case ViewMode::Funnel: return render_funnel(out, cfg, size); + case ViewMode::Waterfall: return render_waterfall(out, cfg, size); + case ViewMode::KPI: return render_kpi_single(out, cfg); + case ViewMode::KPIGrid: return render_kpi_grid(out, cfg); + case ViewMode::Candlestick: return render_candlestick(out, cfg, size); + case ViewMode::Radar: return render_radar(out, cfg, size); + } + return false; +} + +} // namespace viz diff --git a/playground/tables/viz.h b/playground/tables/viz.h new file mode 100644 index 0000000..96b364c --- /dev/null +++ b/playground/tables/viz.h @@ -0,0 +1,35 @@ +// viz: dispatcher de visualizaciones ImPlot sobre StageOutput. +// Cada modo elige automaticamente las columnas relevantes (primera categorica, +// primera o varias numericas) salvo override desde UI. +#pragma once + +#include "data_table_logic.h" +#include "imgui.h" +#include + +namespace viz { + +// Render principal. Devuelve true si renderiza el modo solicitado, false si +// no se cumplen pre-condiciones (faltan cols numericas/categoricas etc.). +// +// `size`: ImVec2(-1,-1) usa todo el espacio disponible. +// `out`: output del stage activo (headers, types, cells flat row-major). +bool render(const data_table::StageOutput& out, + data_table::ViewMode mode, + const data_table::ViewConfig& cfg, + ImVec2 size = ImVec2(-1, -1)); + +// Helper expuesto: encuentra primera col numerica. -1 si ninguna. +int first_numeric_col(const data_table::StageOutput& out); + +// Helper: primera col categorica (String/Date/Bool/Json o Int con muchos +// uniques bajos — heuristica). -1 si ninguna. +int first_category_col(const data_table::StageOutput& out); + +// Helper: extrae columna como vector. Cells no parseables -> NaN. +std::vector extract_numeric(const data_table::StageOutput& out, int col); + +// Helper: extrae columna como vector (categorias). +std::vector extract_category(const data_table::StageOutput& out, int col); + +} // namespace viz