chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)
Snapshot de WIP acumulado de sesiones previas antes de merge wave 1 del flow 0008 (kanban_cpp + agent_runner_api + DoD schema). Incluye: - dev/flows/0008-kanban-cpp-and-agent-workflows.md - dev/issues/0112-0119*.md (7 sub-issues) - WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,16 +60,10 @@ void about_window_render() {
|
||||
ImGui::TextWrapped("%s", g_description.c_str());
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// --- Framework version (issue 0097) ---
|
||||
ImGui::Text("Framework");
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("v%s", fn::framework_version());
|
||||
|
||||
// --- Modules consumidos por la app (issue 0097) ---
|
||||
// codegen_app_modules.py auto-prepende `framework_cpp` a uses_modules de
|
||||
// toda app C++, asi que la tabla Modules SIEMPRE lista al framework con
|
||||
// su version + cada modulo declarado en app.md::uses_modules.
|
||||
if (fn::app_modules_count > 0) {
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado.
|
||||
|
||||
#include "core/compute_column_stats.h"
|
||||
#include "core/auto_detect_type.h" // parse_number
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado.
|
||||
#pragma once
|
||||
|
||||
#include "core/auto_detect_type.h" // parse_number reutilizado en la impl
|
||||
// NOTE: auto_detect_type.h (parse_number) is required by the .cpp impl only.
|
||||
// It is NOT included here because data_table_types.h includes this header,
|
||||
// and auto_detect_type.h itself references ColumnType (defined in
|
||||
// data_table_types.h) — that would create a circular include.
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#include "core/compute_ring_layout.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional> // std::hash
|
||||
#include <unordered_map>
|
||||
|
||||
namespace fn_ring {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constantes internas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static constexpr float kPi = 3.14159265358979323846f;
|
||||
static constexpr float kTwoPi = 2.0f * kPi;
|
||||
|
||||
// Radio minimo para ring 0 cuando ring_radii[0]==0 (evita colocar nodos en el
|
||||
// origen exacto, reservado para HUD).
|
||||
static constexpr float kRing0InnerMin = 30.0f;
|
||||
|
||||
// Umbral: si el bin tiene mas nodos de los que caben radialmente con separacion
|
||||
// minima MIN_RADIAL_SPACING, activamos jitter angular.
|
||||
static constexpr float kMinRadialSpacing = 18.0f;
|
||||
|
||||
// Amplitud maxima del jitter angular: ±0.4 del half-sector (se mantiene dentro
|
||||
// del sector para no solapar el vecino).
|
||||
static constexpr float kJitterMaxFraction = 0.4f;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Hash deterministico FNV-1a 32 bits (estable cross-platform, sin depender de
|
||||
/// std::hash<string> que puede variar entre ABI/compiladores).
|
||||
static uint32_t fnv1a_32(const std::string& s) {
|
||||
uint32_t h = 2166136261u;
|
||||
for (unsigned char c : s) {
|
||||
h ^= c;
|
||||
h *= 16777619u;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/// Devuelve valor deterministico en [-1.0, +1.0] usando el hash del id.
|
||||
static float id_jitter(const std::string& id) {
|
||||
uint32_t h = fnv1a_32(id);
|
||||
// Normalizar a [-1, +1]
|
||||
float v = static_cast<float>(h) / static_cast<float>(0xFFFFFFFFu); // [0,1]
|
||||
return v * 2.0f - 1.0f; // [-1,+1]
|
||||
}
|
||||
|
||||
/// Devuelve el mapa canonico de status → ring.
|
||||
static StatusRingMap default_status_map() {
|
||||
return {
|
||||
{"completado", 0},
|
||||
{"completed", 0},
|
||||
{"in-progress", 1},
|
||||
{"pendiente_unlocked", 2},
|
||||
{"unlocked", 2},
|
||||
{"pending", 2},
|
||||
{"pendiente", 3},
|
||||
{"locked", 3},
|
||||
{"deferred", 4},
|
||||
{"bloqueado", 4},
|
||||
};
|
||||
}
|
||||
|
||||
/// Busca status en el mapa. Retorna -1 si no encontrado.
|
||||
static int lookup_ring(const std::string& status, const StatusRingMap& smap) {
|
||||
for (auto& [k, v] : smap) {
|
||||
if (k == status) return v;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Busca domain en el orden. Retorna n_sectors-1 si no encontrado.
|
||||
static int lookup_sector(const std::string& domain,
|
||||
const DomainOrder& order,
|
||||
int n_sectors) {
|
||||
for (int i = 0; i < static_cast<int>(order.size()); ++i) {
|
||||
if (order[i] == domain) return i;
|
||||
}
|
||||
return n_sectors - 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementacion principal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::vector<LayoutOutput>
|
||||
compute_ring_layout(const std::vector<LayoutInput>& nodes,
|
||||
const LayoutConfig& cfg,
|
||||
const StatusRingMap& status_map_in,
|
||||
const DomainOrder& domain_order) {
|
||||
if (nodes.empty()) return {};
|
||||
|
||||
const StatusRingMap& smap = status_map_in.empty()
|
||||
? default_status_map()
|
||||
: status_map_in;
|
||||
|
||||
const int n_rings = static_cast<int>(cfg.ring_radii.size()) - 1;
|
||||
const int n_sectors = cfg.n_sectors > 0 ? cfg.n_sectors : 18;
|
||||
const float sector_angle = kTwoPi / static_cast<float>(n_sectors);
|
||||
const float half_sector = sector_angle * 0.5f;
|
||||
|
||||
// Preasignar outputs con ring=-1 (descartados por defecto)
|
||||
std::vector<LayoutOutput> out(nodes.size());
|
||||
for (size_t i = 0; i < nodes.size(); ++i) {
|
||||
out[i].id = nodes[i].id;
|
||||
}
|
||||
|
||||
// --- Paso 1: mapear cada nodo a (ring, sector) ---
|
||||
// indices_in_bin[ring][sector] = lista de indices en nodes[]
|
||||
using BinKey = std::pair<int,int>;
|
||||
struct BinKeyHash {
|
||||
size_t operator()(const BinKey& k) const noexcept {
|
||||
return std::hash<int>()(k.first) ^ (std::hash<int>()(k.second) << 16);
|
||||
}
|
||||
};
|
||||
std::unordered_map<BinKey, std::vector<size_t>, BinKeyHash> bins;
|
||||
|
||||
for (size_t i = 0; i < nodes.size(); ++i) {
|
||||
int ring = lookup_ring(nodes[i].status, smap);
|
||||
if (ring < 0 || ring >= n_rings) {
|
||||
// Nodo descartado: ring=-1, x/y=0 (ya inicializado)
|
||||
continue;
|
||||
}
|
||||
int sector = lookup_sector(nodes[i].domain, domain_order, n_sectors);
|
||||
out[i].ring = ring;
|
||||
out[i].sector = sector;
|
||||
bins[{ring, sector}].push_back(i);
|
||||
}
|
||||
|
||||
// --- Paso 2: posicionar nodos dentro de cada bin ---
|
||||
for (auto& [key, indices] : bins) {
|
||||
const int ring = key.first;
|
||||
const int sector = key.second;
|
||||
|
||||
// Ordenar bin: recency desc, id asc (deterministico)
|
||||
std::sort(indices.begin(), indices.end(),
|
||||
[&](size_t a, size_t b) {
|
||||
float ra = nodes[a].recency;
|
||||
float rb = nodes[b].recency;
|
||||
if (ra != rb) return ra > rb;
|
||||
return nodes[a].id < nodes[b].id;
|
||||
});
|
||||
|
||||
// Asignar rank_in_bin
|
||||
for (int rank = 0; rank < static_cast<int>(indices.size()); ++rank) {
|
||||
out[indices[rank]].rank_in_bin = rank;
|
||||
}
|
||||
|
||||
// Radio interior y exterior del ring
|
||||
float r_inner = cfg.ring_radii[ring];
|
||||
float r_outer = cfg.ring_radii[ring + 1];
|
||||
|
||||
// Caso especial: ring 0 con radio interno == 0
|
||||
if (r_inner == 0.0f) r_inner = kRing0InnerMin;
|
||||
|
||||
// Aplicar padding
|
||||
float r_lo = r_inner + cfg.bin_padding;
|
||||
float r_hi = r_outer - cfg.bin_padding;
|
||||
if (r_lo > r_hi) {
|
||||
// Bin demasiado estrecho: usar el punto medio
|
||||
r_lo = r_hi = (r_inner + r_outer) * 0.5f;
|
||||
}
|
||||
|
||||
const int N = static_cast<int>(indices.size());
|
||||
|
||||
// Angulo central del sector (sin jitter)
|
||||
float theta_center = cfg.start_angle
|
||||
+ (static_cast<float>(sector) + 0.5f) * sector_angle;
|
||||
|
||||
// Determinar si activamos jitter angular (bin sobrecargado radialmente)
|
||||
float band_height = r_hi - r_lo;
|
||||
int radial_capacity = (band_height < 1.0f)
|
||||
? 1
|
||||
: std::max(1, static_cast<int>(band_height / kMinRadialSpacing));
|
||||
bool use_jitter = (N > radial_capacity);
|
||||
|
||||
// Posicionar cada nodo
|
||||
for (int rank = 0; rank < N; ++rank) {
|
||||
size_t idx = indices[rank];
|
||||
|
||||
// Radio: distribucion uniforme en la banda
|
||||
float r;
|
||||
if (N == 1) {
|
||||
r = (r_lo + r_hi) * 0.5f;
|
||||
} else {
|
||||
r = r_lo + (static_cast<float>(rank) + 0.5f) * (r_hi - r_lo)
|
||||
/ static_cast<float>(N);
|
||||
}
|
||||
|
||||
// Sub-jitter angular deterministico si bin sobrecargado
|
||||
float jitter = 0.0f;
|
||||
if (use_jitter) {
|
||||
float raw = id_jitter(nodes[idx].id); // en [-1,+1]
|
||||
jitter = raw * half_sector * kJitterMaxFraction;
|
||||
}
|
||||
|
||||
float theta = theta_center + jitter;
|
||||
out[idx].x = cfg.center_x + r * std::cos(theta);
|
||||
out[idx].y = cfg.center_y + r * std::sin(theta);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace fn_ring
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/// fn_ring — geometria pura para layout en anillos concentricos + sectores radiales.
|
||||
/// Pure: sin I/O, sin estado global, sin RNG. Misma entrada → mismo output siempre.
|
||||
/// C++17 STL only.
|
||||
|
||||
namespace fn_ring {
|
||||
|
||||
struct LayoutInput {
|
||||
std::string id; // identidad unica para mapeo deterministico
|
||||
std::string status; // categoria libre — mapeada al ring via status_map
|
||||
std::string domain; // categoria libre — mapeada al sector via domain_order
|
||||
float recency = 0.0f; // 0..1, ordena DENTRO de un (ring,sector) bin (desc)
|
||||
};
|
||||
|
||||
struct LayoutOutput {
|
||||
std::string id;
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
int ring = -1; // -1 = nodo descartado (status no mapeado)
|
||||
int sector = 0;
|
||||
int rank_in_bin = 0; // 0..N-1 dentro del bin (ring,sector)
|
||||
};
|
||||
|
||||
struct LayoutConfig {
|
||||
int n_sectors = 18;
|
||||
float center_x = 0.0f;
|
||||
float center_y = 0.0f;
|
||||
// ring_radii[i] = radio interno del ring i
|
||||
// ring_radii[i+1] = radio externo del ring i
|
||||
// Default: 5 rings (0..4)
|
||||
std::vector<float> ring_radii { 0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f };
|
||||
float bin_padding = 14.0f; // padding interior del bin en pixels
|
||||
float start_angle = 0.0f; // rotacion global del sector 0 en radianes
|
||||
};
|
||||
|
||||
/// Mapea strings de status a indice de ring.
|
||||
/// Status no listado → ring -1 (nodo descartado).
|
||||
///
|
||||
/// Default canonico (usado cuando se pasa vector vacio):
|
||||
/// {"completado",0}, {"completed",0},
|
||||
/// {"in-progress",1},
|
||||
/// {"pendiente_unlocked",2}, {"unlocked",2}, {"pending",2},
|
||||
/// {"pendiente",3}, {"locked",3},
|
||||
/// {"deferred",4}, {"bloqueado",4}
|
||||
using StatusRingMap = std::vector<std::pair<std::string, int>>;
|
||||
|
||||
/// Mapea strings de domain a indice de sector [0..n_sectors-1].
|
||||
/// Domains no listados → sector n_sectors-1 (sector "otros").
|
||||
using DomainOrder = std::vector<std::string>;
|
||||
|
||||
/// Compute ring+sector layout deterministico.
|
||||
///
|
||||
/// Para cada nodo:
|
||||
/// 1. ring = status_map[node.status]; descartar si -1.
|
||||
/// 2. sector = index(domain_order, node.domain); fallback n_sectors-1.
|
||||
/// 3. Bin (ring, sector): ordenar por (recency desc, id asc), distribuir
|
||||
/// uniformemente en la banda radial con padding. Sub-jitter angular
|
||||
/// deterministico via hash del id cuando el bin esta sobrecargado.
|
||||
/// 4. theta = start_angle + (sector + 0.5) * (2*PI / n_sectors) + jitter
|
||||
/// 5. x = center.x + r * cos(theta); y = center.y + r * sin(theta)
|
||||
///
|
||||
/// Ring 0 con radio interno 0: usa r_inner = 30.0f para no colocar nodos
|
||||
/// en el origen exacto (reservado para HUD overlay).
|
||||
std::vector<LayoutOutput>
|
||||
compute_ring_layout(const std::vector<LayoutInput>& nodes,
|
||||
const LayoutConfig& cfg,
|
||||
const StatusRingMap& status_map = {},
|
||||
const DomainOrder& domain_order = {});
|
||||
|
||||
} // namespace fn_ring
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: compute_ring_layout
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: core
|
||||
purity: pure
|
||||
version: "1.0.0"
|
||||
signature: "std::vector<LayoutOutput> compute_ring_layout(const std::vector<LayoutInput>& nodes, const LayoutConfig& cfg, const StatusRingMap& status_map, const DomainOrder& domain_order)"
|
||||
description: "Calcula posiciones (x,y) deterministicas para layout en anillos concentricos por status + sectores radiales por domain. Pure, sin fisicas, output reproducible. Util para skill_tree, dashboards de roadmap, mapas de capability."
|
||||
tags: [layout, rings, sectors, polar, dashboard, registry, skill-tree]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["core/compute_ring_layout.h"]
|
||||
params:
|
||||
- name: nodes
|
||||
desc: "Vector de LayoutInput. Cada nodo tiene id (unico), status (bucket de ring), domain (sector), recency (0..1, ordena dentro del bin desc)."
|
||||
- name: cfg
|
||||
desc: "LayoutConfig: n_sectors, center_x/y, ring_radii (bordes de los anillos), bin_padding, start_angle. Defaults validos para 5 rings / 18 sectores / canvas 850px de radio."
|
||||
- name: status_map
|
||||
desc: "Mapa status→ring. Vector vacio usa el mapa canonico (completado→0, in-progress→1, unlocked→2, pendiente→3, deferred→4). Status no encontrado → ring=-1 (descartado)."
|
||||
- name: domain_order
|
||||
desc: "Lista ordenada de domains para asignar sector. Domain fuera de la lista → sector n_sectors-1 (sector 'otros'). Vector vacio → todos los domains caen en sector n_sectors-1."
|
||||
output: "Vector de LayoutOutput en el mismo orden que nodes[]. Cada salida: id, x, y (posicion en world units), ring (-1=descartado), sector, rank_in_bin (orden dentro del (ring,sector))."
|
||||
tested: true
|
||||
tests:
|
||||
- "empty_input_empty_output"
|
||||
- "single_node_centered_in_bin"
|
||||
- "two_nodes_same_bin_radial_distribution"
|
||||
- "default_status_map"
|
||||
- "unmapped_status_returns_ring_minus_one"
|
||||
- "domain_not_in_order_falls_back_to_last_sector"
|
||||
- "deterministic_repeated_call"
|
||||
- "ring_zero_avoids_origin"
|
||||
- "sector_wrap_around_last_sector"
|
||||
- "golden_snapshot_30_nodes"
|
||||
test_file_path: "cpp/tests/test_compute_ring_layout.cpp"
|
||||
file_path: "cpp/functions/core/compute_ring_layout.cpp"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "core/compute_ring_layout.h"
|
||||
|
||||
fn_ring::LayoutConfig cfg;
|
||||
// cfg usa defaults: 5 rings, 18 sectors, ring_radii={0,150,280,450,650,850}
|
||||
|
||||
fn_ring::DomainOrder order = {"core", "infra", "finance", "datascience"};
|
||||
|
||||
std::vector<fn_ring::LayoutInput> nodes = {
|
||||
{"0001", "completado", "core", 1.0f},
|
||||
{"0002", "pendiente", "infra", 0.5f},
|
||||
{"0003", "in-progress", "finance", 0.8f},
|
||||
{"0004", "deferred", "core", 0.1f},
|
||||
};
|
||||
|
||||
auto out = fn_ring::compute_ring_layout(nodes, cfg, {}, order);
|
||||
// out[0]: ring=0, sector=0 (completado → ring0, core → sector0)
|
||||
// out[1]: ring=3, sector=1 (pendiente → ring3, infra → sector1)
|
||||
// out[2]: ring=1, sector=2 (in-progress→ ring1, finance → sector2)
|
||||
// out[3]: ring=4, sector=0 (deferred → ring4, core → sector0)
|
||||
|
||||
// Integrar en main.cpp del skill_tree:
|
||||
for (auto& o : out) {
|
||||
if (o.ring < 0) continue; // nodo descartado
|
||||
// ImGui::SetCursorScreenPos({base.x + o.x, base.y + o.y});
|
||||
// draw_node(o.id);
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas colocar N entidades en un mapa polar de progreso donde el anillo codifica el estado de avance (completado → centro, pendiente → exterior) y el sector codifica el dominio o categoria. Casos canonicos: skill_tree de issues/capabilities, roadmap visual de un proyecto, mapa de cobertura del registry por dominio.
|
||||
|
||||
La funcion calcula posiciones una vez; la animacion (interpolacion entre dos snapshots) la hace el caller en main.cpp, no esta funcion.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Hash deterministico dentro de la build**: el sub-jitter angular usa FNV-1a 32-bit sobre el id (no `std::hash<string>`), lo que garantiza estabilidad cross-platform dentro del mismo proceso. El output es reproducible entre compiladores distintos.
|
||||
- **Domain fuera de DomainOrder**: siempre cae en sector `n_sectors-1`. Si el caller quiere que todos los domains inesperados esten dispersos en lugar de apilados en el ultimo sector, debe pasar un `DomainOrder` exhaustivo o usar un sector dedicado `"otros"`.
|
||||
- **Ring 0 con `ring_radii[0]==0`**: la funcion usa `r_inner=30.0f` automaticamente para no colocar nodos en el origen. Si el caller quiere usar el origen, debe pasar `ring_radii[0] > 0`.
|
||||
- **Bins muy densamente cargados**: cuando el bin tiene mas nodos que la capacidad radial (`band_height / 18px`), se activa jitter angular `±0.4 * half_sector`. Los nodos siguen dentro del sector pero el angulo no es exactamente el centro del sector. Para bins con N > ~20 nodos el solapamiento visual es inevitable sin escalado del canvas.
|
||||
- **`status_map` vacio**: se usa el mapa canonico con 5 rings. Si el caller usa status propios, DEBE pasar su propio `StatusRingMap`; de lo contrario todos cairan en ring=-1 (descartados).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(sin bumps aun — v1.0.0)
|
||||
@@ -5,6 +5,8 @@
|
||||
// v1.4.0: ChipRule / ColorStop / CategoricalChip / ColorScale renderers.
|
||||
#pragma once
|
||||
|
||||
#include "compute_column_stats.h"
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -49,13 +51,68 @@ struct Filter {
|
||||
std::string value;
|
||||
};
|
||||
|
||||
// ColorStop: one stop in an N-color gradient. Used by ColorRule (NumericRange)
|
||||
// y por ColumnSpec (ColorScale renderer). Definido aqui (no abajo) para que
|
||||
// ColorRule pueda contenerlo sin forward decl.
|
||||
struct ColorStop {
|
||||
float position; // 0.0 (leftmost/min) to 1.0 (rightmost/max)
|
||||
std::string color; // "#rrggbb" hex color at this stop
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Drill extendido (fase 10). Ver issue 0079.
|
||||
// Definidos aqui (no al final) para que State pueda contener vector<DrillStep>.
|
||||
// ----------------------------------------------------------------------------
|
||||
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
|
||||
|
||||
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
|
||||
|
||||
// Step de drill grabado para history undo/redo (fase 10).
|
||||
struct DrillStep {
|
||||
int target_stage = -1; // stage donde se anadio el filter
|
||||
int filter_pos = -1; // index en target_stage.filters
|
||||
int prev_active_stage = 0; // active_stage antes del drill
|
||||
Filter added; // filter para redo
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// ColorRule kind (v1.5.0): pintado condicional con tres modos.
|
||||
// CellBg — bg de celda cuando valor == `equals` (back-compat).
|
||||
// CategoricalDot — dot a la izquierda del texto, colores autoasignados por
|
||||
// valor distinto (palette deterministica). Util para
|
||||
// categoricas con muchos valores donde no quieres definir
|
||||
// cada color a mano.
|
||||
// NumericRange — gradiente continuo de N colores (range_stops) sobre el
|
||||
// bg de la celda. Para columnas numericas.
|
||||
// ----------------------------------------------------------------------------
|
||||
enum class ColorRuleKind : uint8_t {
|
||||
CellBg = 0, // legacy (equals + color)
|
||||
CategoricalDot = 1, // dots autoasignados por valor distinto
|
||||
NumericRange = 2, // gradiente N-color sobre rango numerico
|
||||
};
|
||||
|
||||
// ColorRule: pintado condicional de celdas (UI helper).
|
||||
// ----------------------------------------------------------------------------
|
||||
// v1.5.0: anade `kind` + campos para CategoricalDot / NumericRange.
|
||||
struct ColorRule {
|
||||
int col;
|
||||
std::string equals;
|
||||
unsigned int color;
|
||||
int col;
|
||||
std::string equals; // CellBg: match value; otros: ignorado
|
||||
unsigned int color; // CellBg: bg color; otros: ignorado
|
||||
|
||||
// v1.5.0 fields (defaults preservan back-compat — kind=CellBg).
|
||||
ColorRuleKind kind = ColorRuleKind::CellBg;
|
||||
|
||||
// CategoricalDot: alpha del relleno del dot (0..1). Tamaño en px (default 6).
|
||||
float dot_alpha = 1.0f;
|
||||
float dot_radius_px = 4.0f;
|
||||
// Si vacio -> autoasigna desde palette interna. Si no, mapping fijo:
|
||||
// pares (valor, "#rrggbb") consultados en orden.
|
||||
std::vector<std::pair<std::string, std::string>> dot_map;
|
||||
|
||||
// NumericRange: gradiente continuo. range_min..range_max + N>=2 stops.
|
||||
double range_min = 0.0;
|
||||
double range_max = 1.0;
|
||||
float range_alpha = 0.25f; // [0..1]; bg tint opacity
|
||||
std::vector<ColorStop> range_stops; // declarado mas abajo; vacio -> default green→amber→red
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -189,11 +246,7 @@ struct ChipRule {
|
||||
std::string color; // "#rrggbb" hex color for the filled circle
|
||||
};
|
||||
|
||||
// ColorStop: one stop in an N-color gradient for ColorScale renderer (v1.4.0).
|
||||
struct ColorStop {
|
||||
float position; // 0.0 (leftmost/min) to 1.0 (rightmost/max)
|
||||
std::string color; // "#rrggbb" hex color at this stop
|
||||
};
|
||||
// (ColorStop defined earlier, near Filter, so ColorRule can reference it.)
|
||||
|
||||
// ColumnSpec: rendering spec for one column. Indexed by column position.
|
||||
struct ColumnSpec {
|
||||
@@ -331,6 +384,41 @@ struct State {
|
||||
bool chrome_user_set = true;
|
||||
bool chrome_user_visible = false;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// v1.5.0 — per-table UI state. Antes vivia en UiState singleton, lo cual
|
||||
// hacia que toggle "Show stats" / seleccion de celdas / drill-history /
|
||||
// row inspector se aplicasen a TODAS las tablas a la vez. Ahora cada
|
||||
// State los lleva. Modal/popup state (ask AI, edit chips, etc.) sigue en
|
||||
// UiState porque solo uno esta abierto en toda la app a la vez.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
// Stats panel (cabecera de col muestra min/max/percentiles/hist).
|
||||
bool stats_mode = false;
|
||||
std::vector<ColStats> stats_cache;
|
||||
// Invalidacion del cache (data-identity y filter-set).
|
||||
const char* const* stats_last_cells = nullptr;
|
||||
int stats_last_rows = -1;
|
||||
int stats_last_eff_cols = -1;
|
||||
size_t stats_last_filter_h = (size_t)-1;
|
||||
int stats_last_visible = -1;
|
||||
|
||||
// Cell selection (drag-select rectangular Ctrl+C-able).
|
||||
int sel_anchor_row = -1;
|
||||
int sel_anchor_col = -1;
|
||||
int sel_end_row = -1;
|
||||
int sel_end_col = -1;
|
||||
bool sel_active = false;
|
||||
bool sel_dragging = false;
|
||||
|
||||
// Row inspector modal target (-1 = closed).
|
||||
int inspect_row = -1;
|
||||
bool inspect_open = false;
|
||||
|
||||
// Drill history (fase 10) — per-table porque cada tabla tiene su propio
|
||||
// pipeline de filters.
|
||||
std::vector<DrillStep> drill_back;
|
||||
std::vector<DrillStep> drill_forward;
|
||||
|
||||
// Helpers (definidos en compute_stage.cpp).
|
||||
Stage& raw();
|
||||
const Stage& raw() const;
|
||||
@@ -339,19 +427,4 @@ struct State {
|
||||
void ensure_stage0();
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Drill extendido (fase 10). Ver issue 0079.
|
||||
// ----------------------------------------------------------------------------
|
||||
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
|
||||
|
||||
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
|
||||
|
||||
// Step de drill grabado para history undo/redo (fase 10).
|
||||
struct DrillStep {
|
||||
int target_stage = -1; // stage donde se anadio el filter
|
||||
int filter_pos = -1; // index en target_stage.filters
|
||||
int prev_active_stage = 0; // active_stage antes del drill
|
||||
Filter added; // filter para redo
|
||||
};
|
||||
|
||||
} // namespace data_table
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
#include "core/parse_md_frontmatter.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
|
||||
namespace fn_md {
|
||||
|
||||
namespace {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::string trim(const std::string& s) {
|
||||
const auto b = s.find_first_not_of(" \t\r");
|
||||
if (b == std::string::npos) return {};
|
||||
const auto e = s.find_last_not_of(" \t\r");
|
||||
return s.substr(b, e - b + 1);
|
||||
}
|
||||
|
||||
// Strip a trailing ` # comment` from a value string.
|
||||
// Only strips if the `#` is preceded by at least one space and is outside
|
||||
// any quote context. We take a simple approach: after stripping outer quotes
|
||||
// we look for ` #` outside of them.
|
||||
static std::string strip_comment(const std::string& s) {
|
||||
// If the whole string is quoted, skip comment stripping; the comment would
|
||||
// be inside the quotes and should be preserved as literal text.
|
||||
if (s.size() >= 2 &&
|
||||
((s.front() == '"' && s.back() == '"') ||
|
||||
(s.front() == '\'' && s.back() == '\''))) {
|
||||
return s;
|
||||
}
|
||||
const auto pos = s.find(" #");
|
||||
if (pos == std::string::npos) return s;
|
||||
return trim(s.substr(0, pos));
|
||||
}
|
||||
|
||||
// Remove surrounding " or ' quotes from a value, if present.
|
||||
static std::string unquote(const std::string& s) {
|
||||
if (s.size() >= 2 &&
|
||||
((s.front() == '"' && s.back() == '"') ||
|
||||
(s.front() == '\'' && s.back() == '\''))) {
|
||||
return s.substr(1, s.size() - 2);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Parse an inline YAML list: [a, b, c] or []
|
||||
static std::vector<std::string> parse_inline_list(const std::string& s) {
|
||||
std::vector<std::string> result;
|
||||
// strip brackets
|
||||
const auto lb = s.find('[');
|
||||
const auto rb = s.rfind(']');
|
||||
if (lb == std::string::npos || rb == std::string::npos || lb >= rb)
|
||||
return result;
|
||||
const std::string inner = s.substr(lb + 1, rb - lb - 1);
|
||||
if (trim(inner).empty()) return result;
|
||||
|
||||
// split on commas respecting quotes
|
||||
std::string token;
|
||||
bool in_quote = false;
|
||||
char quote_ch = 0;
|
||||
for (char c : inner) {
|
||||
if (!in_quote && (c == '"' || c == '\'')) {
|
||||
in_quote = true;
|
||||
quote_ch = c;
|
||||
token += c;
|
||||
} else if (in_quote && c == quote_ch) {
|
||||
in_quote = false;
|
||||
token += c;
|
||||
} else if (!in_quote && c == ',') {
|
||||
result.push_back(unquote(trim(token)));
|
||||
token.clear();
|
||||
} else {
|
||||
token += c;
|
||||
}
|
||||
}
|
||||
const auto t = unquote(trim(token));
|
||||
if (!t.empty()) result.push_back(t);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Split a frontmatter line into (key, raw_value).
|
||||
// Returns false if the line has no `:`.
|
||||
static bool split_kv(const std::string& line, std::string& key, std::string& raw_val) {
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string::npos) return false;
|
||||
key = trim(line.substr(0, colon));
|
||||
raw_val = trim(line.substr(colon + 1));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line iterator helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::vector<std::string> split_lines(const std::string& s) {
|
||||
std::vector<std::string> lines;
|
||||
std::istringstream ss(s);
|
||||
std::string l;
|
||||
while (std::getline(ss, l)) lines.push_back(l);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Returns true if a line looks like a block-list item: optional leading
|
||||
// whitespace, then `- `.
|
||||
static bool is_list_item(const std::string& line, std::string& item_val) {
|
||||
const auto b = line.find_first_not_of(" \t");
|
||||
if (b == std::string::npos) return false;
|
||||
if (line[b] != '-') return false;
|
||||
// Must have space (or be end) after `-`
|
||||
if (b + 1 < line.size() && line[b + 1] != ' ') return false;
|
||||
item_val = trim(line.substr(b + 1));
|
||||
item_val = unquote(item_val);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Frontmatter parse_md_frontmatter(const std::string& content) {
|
||||
Frontmatter fm;
|
||||
|
||||
const auto lines = split_lines(content);
|
||||
if (lines.empty()) return fm;
|
||||
|
||||
// Check opening `---`
|
||||
if (trim(lines[0]) != "---") {
|
||||
fm.body = content;
|
||||
return fm;
|
||||
}
|
||||
|
||||
fm.has_frontmatter = true;
|
||||
|
||||
// Find closing `---`
|
||||
std::size_t close_idx = std::string::npos;
|
||||
for (std::size_t i = 1; i < lines.size(); ++i) {
|
||||
if (trim(lines[i]) == "---") {
|
||||
close_idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine where the body starts (after the closing `---\n`)
|
||||
if (close_idx == std::string::npos) {
|
||||
// No closing delimiter: treat everything after line 0 as frontmatter,
|
||||
// body is empty.
|
||||
close_idx = lines.size();
|
||||
} else {
|
||||
// Reconstruct body from lines after close_idx
|
||||
std::string body;
|
||||
for (std::size_t i = close_idx + 1; i < lines.size(); ++i) {
|
||||
body += lines[i] + '\n';
|
||||
}
|
||||
fm.body = body;
|
||||
}
|
||||
|
||||
// Parse frontmatter lines [1 .. close_idx)
|
||||
std::size_t i = 1;
|
||||
while (i < close_idx) {
|
||||
const std::string& line = lines[i];
|
||||
|
||||
// Skip blank lines and top-level comment lines
|
||||
const std::string tl = trim(line);
|
||||
if (tl.empty() || tl[0] == '#') {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string key, raw_val;
|
||||
if (!split_kv(line, key, raw_val)) {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (key.empty()) { ++i; continue; }
|
||||
|
||||
// --- Determine the kind of value ---
|
||||
|
||||
// 1. Inline list: raw_val starts with `[`
|
||||
if (!raw_val.empty() && raw_val[0] == '[') {
|
||||
fm.fields[key] = parse_inline_list(raw_val);
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Block scalar `|` or `>` — unsupported, store empty string
|
||||
if (raw_val == "|" || raw_val == ">") {
|
||||
fm.fields[key] = std::string{};
|
||||
// Skip continuation lines (more indented than current)
|
||||
++i;
|
||||
while (i < close_idx) {
|
||||
const std::string& next = lines[i];
|
||||
if (next.empty()) { ++i; continue; }
|
||||
if (next[0] == ' ' || next[0] == '\t') { ++i; continue; }
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Block list: raw_val is empty and next line(s) are list items
|
||||
if (raw_val.empty()) {
|
||||
// Peek ahead
|
||||
std::string dummy;
|
||||
std::size_t j = i + 1;
|
||||
bool found_items = false;
|
||||
while (j < close_idx) {
|
||||
const std::string& next = lines[j];
|
||||
if (trim(next).empty()) { ++j; continue; }
|
||||
std::string item_val;
|
||||
if (is_list_item(next, item_val)) {
|
||||
found_items = true;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (found_items) {
|
||||
std::vector<std::string> items;
|
||||
++i; // move past the key line
|
||||
while (i < close_idx) {
|
||||
const std::string& next = lines[i];
|
||||
if (trim(next).empty()) { ++i; continue; }
|
||||
std::string item_val;
|
||||
if (is_list_item(next, item_val)) {
|
||||
items.push_back(unquote(item_val));
|
||||
++i;
|
||||
} else {
|
||||
break; // end of block list
|
||||
}
|
||||
}
|
||||
fm.fields[key] = std::move(items);
|
||||
continue;
|
||||
}
|
||||
// Empty value (e.g. `key:` with no list following)
|
||||
fm.fields[key] = std::string{};
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Plain scalar (strip trailing comment, then unquote)
|
||||
const std::string stripped = strip_comment(raw_val);
|
||||
fm.fields[key] = unquote(trim(stripped));
|
||||
++i;
|
||||
}
|
||||
|
||||
return fm;
|
||||
}
|
||||
|
||||
} // namespace fn_md
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
namespace fn_md {
|
||||
|
||||
/// A YAML value: absent, scalar string, or list of strings.
|
||||
using YamlValue = std::variant<std::monostate, std::string, std::vector<std::string>>;
|
||||
|
||||
/// Result of parsing a Markdown file that may contain a YAML frontmatter block.
|
||||
struct Frontmatter {
|
||||
std::unordered_map<std::string, YamlValue> fields;
|
||||
std::string body; ///< content after the closing `---` line
|
||||
bool has_frontmatter = false;
|
||||
};
|
||||
|
||||
/// Parse a Markdown file that may (or may not) begin with a YAML frontmatter
|
||||
/// block delimited by `---` lines.
|
||||
///
|
||||
/// Supported YAML subset (flat only, no nested maps):
|
||||
/// key: value -> string (bare, or "quoted", or 'quoted')
|
||||
/// key: [a, b, c] -> inline list -> vector<string>
|
||||
/// key: -> block list when the next indented lines are
|
||||
/// - item ` - item` -> vector<string>
|
||||
/// key: | -> unsupported block scalar; stored as ""
|
||||
/// # comment -> ignored (also inline trailing comments)
|
||||
///
|
||||
/// Notes:
|
||||
/// - Keys and values are trimmed of leading/trailing whitespace.
|
||||
/// - A value like `description: "foo: bar"` splits on the FIRST colon only.
|
||||
/// - Pure function: no I/O, no logging, no side-effects.
|
||||
Frontmatter parse_md_frontmatter(const std::string& content);
|
||||
|
||||
} // namespace fn_md
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: parse_md_frontmatter
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "Frontmatter parse_md_frontmatter(const std::string& content)"
|
||||
description: "Parsea frontmatter YAML simple (subset: key:value, listas inline [a,b], listas multilinea con - item) de un .md y devuelve struct con fields map + body. Pure, sin dependencias externas."
|
||||
tags: [markdown, frontmatter, yaml, parser, issues, meta, registry]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["core/parse_md_frontmatter.h"]
|
||||
example: |
|
||||
#include "core/parse_md_frontmatter.h"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
|
||||
std::stringstream ss; ss << f.rdbuf();
|
||||
auto fm = fn_md::parse_md_frontmatter(ss.str());
|
||||
auto title = std::get<std::string>(fm.fields["title"]);
|
||||
auto domain = std::get<std::vector<std::string>>(fm.fields["domain"]);
|
||||
tested: true
|
||||
tests:
|
||||
- "parses_no_frontmatter"
|
||||
- "parses_simple_key_value"
|
||||
- "parses_quoted_strings"
|
||||
- "parses_inline_list"
|
||||
- "parses_multiline_list"
|
||||
- "parses_body_after_frontmatter"
|
||||
- "parses_empty_inline_list"
|
||||
- "parses_strips_trailing_comment"
|
||||
- "parses_real_issue_0109"
|
||||
- "parses_real_issues_golden"
|
||||
test_file_path: "cpp/tests/test_parse_md_frontmatter.cpp"
|
||||
file_path: "cpp/functions/core/parse_md_frontmatter.cpp"
|
||||
params:
|
||||
- name: content
|
||||
desc: "Contenido completo del archivo .md como string. Puede o no comenzar con bloque frontmatter `---...---`."
|
||||
output: "Struct Frontmatter con: fields (unordered_map<string, YamlValue>) donde YamlValue = monostate|string|vector<string>; body (string con contenido despues del segundo ---); has_frontmatter (bool)."
|
||||
notes: |
|
||||
Subset YAML deliberado — los frontmatters de issues y flows del registry son planos. Si en el futuro hace falta soportar mapas anidados, considerar embeber yaml-cpp en cpp/vendor/ antes que extender este parser.
|
||||
---
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando una app C++ necesita leer metadata de archivos Markdown del registry (issues, flows, app.md, analysis.md, type.md). No requiere libreria externa ni yaml-cpp.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Subset YAML: NO soporta mapas anidados, NO soporta block scalars `|` ni `>` (devuelve string vacio en esos casos).
|
||||
- Si un value contiene `: ` literal debe ir entre comillas: `description: "foo: bar"`.
|
||||
- Comentarios solo se eliminan al final de linea en valores no citados. Un `# comentario` dentro de comillas se preserva como literal.
|
||||
- Block list: las lineas de items deben estar indentadas con al menos un espacio antes de `- `. Un `- item` sin indentacion al nivel raiz se ignora (no es list item de la clave anterior).
|
||||
- `YamlValue` es `std::variant` — usar `std::get<std::string>` o `std::get<std::vector<std::string>>` segun el campo. `std::get_if` es mas seguro si el tipo no es conocido a priori.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "core/parse_md_frontmatter.h"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
|
||||
std::stringstream ss; ss << f.rdbuf();
|
||||
auto fm = fn_md::parse_md_frontmatter(ss.str());
|
||||
|
||||
// Scalar string
|
||||
auto id = std::get<std::string>(fm.fields["id"]); // "0109"
|
||||
auto status = std::get<std::string>(fm.fields["status"]); // "in-progress"
|
||||
|
||||
// List
|
||||
auto domain = std::get<std::vector<std::string>>(fm.fields["domain"]);
|
||||
// domain[0] == "meta", domain[1] == "cpp-stack"
|
||||
|
||||
// Body (content after closing ---)
|
||||
// fm.body starts with "\n# 0109 — skill_tree app..."
|
||||
```
|
||||
Reference in New Issue
Block a user