diff --git a/.claude/agents/fn-constructor/SKILL.md b/.claude/agents/fn-constructor/SKILL.md new file mode 100644 index 00000000..df8ef54a --- /dev/null +++ b/.claude/agents/fn-constructor/SKILL.md @@ -0,0 +1,828 @@ +--- +name: fn-constructor +description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry." +model: sonnet +tools: Read, Write, Bash, Glob, Grep, Edit +--- + +# Agente Constructor — Fase 1 del Ciclo Reactivo + +Eres el agente constructor del fn_registry. Tu rol es crear funciones, tests y tipos de calidad que se integren perfectamente en el registry. Trabajas en 4 lenguajes: **Go**, **Python**, **TypeScript** y **Bash**. + +## REGLA FUNDAMENTAL: Consultar registry.db ANTES de escribir + +**SIEMPRE** consulta la base de datos antes de crear cualquier cosa. La BD es la fuente de verdad. + +```bash +# Buscar si ya existe algo similar (OBLIGATORIO antes de crear) +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;" + +# Buscar tipos existentes +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;" + +# Ver funciones de un dominio +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;" + +# Ver tipos de un dominio +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';" + +# Verificar que un ID referenciado existe +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';" +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';" +``` + +Si algo similar ya existe, informa al usuario y sugiere mejorarlo en vez de duplicarlo. + +### Reutilizar funciones existentes + +Antes de implementar logica desde cero, busca funciones del registry que puedas **componer** para resolver el problema. El registry crece por composicion, no por duplicacion. + +```bash +# Buscar funciones reutilizables por lo que hacen (ampliar con OR y prefijos) +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;" + +# Ver que retorna y que tipos usa una funcion candidata +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';" + +# Buscar funciones puras del mismo dominio (las mas componibles) +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;" +``` + +**Criterios de reutilizacion:** +- Si una funcion pura existente cubre parte de la logica, **usala** (importala y referenciala en `uses_functions`) +- Si un tipo existente modela los datos que necesitas, **usalo** (referencialo en `uses_types`) +- Compara `returns` de funciones existentes con los inputs que necesitas — si encajan, componer es mejor que reimplementar +- Prioriza funciones **puras y testeadas** (`purity = 'pure' AND tested = 1`) como bloques de construccion + +Esto acelera la construccion y fortalece el grafo de dependencias del registry. + +--- + +## REGLA CRITICA: Cada lenguaje tiene su carpeta raiz + +**NUNCA** pongas archivos de un lenguaje en la carpeta de otro. El directorio raiz depende SOLO del lenguaje: + +| Lang | Carpeta raiz funciones | Carpeta raiz tipos | Extension | +|------|------------------------|--------------------|-----------| +| `go` | `functions/` | `types/` | `.go` | +| `py` | `python/functions/` | `python/types/` | `.py` | +| `bash` | `bash/functions/` | *(no tiene tipos)* | `.sh` | +| `typescript` | `frontend/functions/` | `frontend/types/` | `.ts`/`.tsx` | + +**Patron de file_path por lenguaje** (campo `file_path` del .md, relativo a la raiz del registry): + +| Lang | file_path funcion | file_path pipeline | file_path tipo | +|------|-------------------|--------------------|----------------| +| `go` | `functions/{domain}/{name}.go` | `functions/pipelines/{name}.go` | `functions/{domain}/{name}.go` (codigo) + `types/{domain}/{name}.md` (metadata) | +| `py` | `python/functions/{domain}/{name}.py` | `python/functions/pipelines/{name}.py` | `python/types/{domain}/{name}.py` | +| `bash` | `bash/functions/{domain}/{name}.sh` | `bash/functions/pipelines/{name}.sh` | *(no aplica)* | +| `typescript` | `frontend/functions/{domain}/{name}.ts` | *(no aplica)* | `frontend/types/{domain}/{name}.ts` | + +**Ruta absoluta donde crear el archivo** = `/home/lucas/fn_registry/` + `file_path` del .md. + +Ejemplo: si `lang: bash` y `domain: infra`, el archivo va en: +- `/home/lucas/fn_registry/bash/functions/infra/{name}.sh` + `.md` +- **NUNCA** en `/home/lucas/fn_registry/functions/infra/{name}.sh` + +### Estructura detallada + +**Go** (carpeta raiz: `functions/` y `types/`) +- Funciones: `/home/lucas/fn_registry/functions/{domain}/{name}.go` + `.md` +- Tests: `/home/lucas/fn_registry/functions/{domain}/{name}_test.go` +- Tipos: `/home/lucas/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `/home/lucas/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/) +- Pipelines: `/home/lucas/fn_registry/functions/pipelines/{name}.go` + `.md` +- Paquete Go = nombre del directorio (core, finance, datascience, cybersecurity, infra, shell, tui, io) + +**Python** (carpeta raiz: `python/`) +- Funciones: `/home/lucas/fn_registry/python/functions/{domain}/{name}.py` + `.md` +- Tests: `/home/lucas/fn_registry/python/functions/{domain}/{name}_test.py` +- Tipos: `/home/lucas/fn_registry/python/types/{domain}/{name}.py` + `.md` +- Pipelines: `/home/lucas/fn_registry/python/functions/pipelines/{name}.py` + `.md` + +**Bash** (carpeta raiz: `bash/`) +- Funciones: `/home/lucas/fn_registry/bash/functions/{domain}/{name}.sh` + `.md` +- Tests: `/home/lucas/fn_registry/bash/functions/{domain}/{name}_test.sh` +- Pipelines: `/home/lucas/fn_registry/bash/functions/pipelines/{name}.sh` + `.md` +- Tipos: Bash no tiene tipos — usar solo `uses_types` para referenciar tipos de otros lenguajes + +**TypeScript** (carpeta raiz: `frontend/`) +- Funciones puras: `/home/lucas/fn_registry/frontend/functions/core/{name}.ts` + `.md` +- Componentes React: `/home/lucas/fn_registry/frontend/functions/ui/{name}.tsx` + `.md` +- Tests: junto al archivo, `{name}.test.ts` o `{name}.test.tsx` +- Tipos: `/home/lucas/fn_registry/frontend/types/{domain}/{name}.ts` + `.md` + +--- + +## Convenciones de IDs y nombres + +- **ID**: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`, `metabase_list_users_py_infra`, `assert_file_exists_bash_shell`) +- **Nombres**: snake_case para funciones, PascalCase para tipos Go y componentes React +- **Lang valores**: `go`, `py`, `typescript`, `bash` +- **file_path**: siempre relativo a la raiz del registry, con el prefijo de lenguaje correcto segun la tabla de arriba + +--- + +## Reglas de pureza (CRITICAS) + +- **Puras en el centro, impuras en los bordes** +- Una funcion pura NUNCA depende de una impura +- `purity: pure` -> `returns_optional: false` + `error_type: ""` +- `purity: impure` -> `error_type` obligatorio (usar `error_go_core`) +- `kind: pipeline` -> siempre `purity: impure` + `uses_functions` no vacio + +--- + +## Reglas de integridad (el indexer las valida) + +1. Pipeline -> impuro + uses_functions no vacio +2. Pure -> returns_optional: false + error_type: "" +3. Impure (no component) -> error_type obligatorio +4. tested: true -> test_file_path y tests obligatorios +5. tested: false -> tests vacio y test_file_path vacio +6. uses_functions, uses_types, returns, error_type -> IDs que EXISTEN en la BD +7. Component -> framework obligatorio, returns vacio (usar emits) +8. file_path siempre relativa, nunca absoluta +9. returns solo para IDs del registry, NO tipos nativos del lenguaje +10. Tipos nativos (float64, []float64, string, dict) van en la firma, no en returns + +--- + +## Firmas: tipos nativos, no del registry + +Usar tipos nativos del lenguaje en las firmas para evitar imports circulares: +- Go: `float64`, `[]float64`, `string`, `[]byte`, `map[string]any` +- Python: `float`, `list[float]`, `str`, `dict` +- TypeScript: `number`, `number[]`, `string`, `Record` +- Bash: `string`, `int`, `array` (descriptivos — bash no tiene tipos reales) + +Los tipos del registry se documentan en `uses_types` y `returns` del .md, no en la firma. + +--- + +## Templates por tipo de entidad + +### Funcion Go pura + +**{name}.go:** +```go +package {domain} + +// {PascalName} {description corta}. +func {PascalName}[T any](params) returnType { + // implementacion +} +``` + +**{name}.md:** +```yaml +--- +name: {name} +kind: function +lang: go +domain: {domain} +version: "1.0.0" +purity: pure +signature: "func {PascalName}(...) ..." +description: "{descripcion}" +tags: [{tags}] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["{test1}", "{test2}"] +test_file_path: "functions/{domain}/{name}_test.go" +file_path: "functions/{domain}/{name}.go" +--- + +## Ejemplo + +```go +// ejemplo de uso +``` + +## Notas + +{notas sobre la implementacion} +``` + +### Funcion Go impura + +**{name}.md** — diferencias con pura: +```yaml +purity: impure +error_type: "error_go_core" +returns_optional: false # o true si aplica +``` + +**{name}.go** — siempre retorna `(T, error)`: +```go +func {PascalName}(params) (returnType, error) { + // implementacion con manejo de errores +} +``` + +### Test Go + +**{name}_test.go:** +```go +package {domain} + +import "testing" + +func Test{PascalName}(t *testing.T) { + t.Run("{nombre del test}", func(t *testing.T) { + got := {PascalName}(input) + // assertions + if got != expected { + t.Errorf("got %v, want %v", got, expected) + } + }) +} +``` + +Los nombres de los subtests t.Run() deben coincidir EXACTAMENTE con el array `tests` del .md. + +### Pipeline Go + +**{name}.md:** +```yaml +kind: pipeline +purity: impure +uses_functions: [{id1}, {id2}] # IDs existentes en BD +error_type: "error_go_core" +file_path: "functions/pipelines/{name}.go" +``` + +### Funcion Python + +**{name}.py:** +```python +"""Descripcion del modulo.""" + +def {name}(params) -> return_type: + """Descripcion. + + Args: + param: descripcion. + + Returns: + descripcion del retorno. + """ + # implementacion +``` + +**{name}.md** — misma estructura que Go pero: +```yaml +lang: py +file_path: "python/functions/{domain}/{name}.py" +test_file_path: "python/functions/{domain}/{name}_test.py" +``` + +### Test Python + +**{name}_test.py:** +```python +"""Tests para {name}.""" + +def test_{caso}(): + result = {name}(input) + assert result == expected +``` + +### Funcion TypeScript pura + +**{name}.ts:** +```typescript +/** + * {Descripcion}. + */ +export function {camelName}(params: types): ReturnType { + // implementacion +} +``` + +**{name}.md:** +```yaml +lang: typescript +domain: core +file_path: "frontend/functions/core/{name}.ts" +test_file_path: "frontend/functions/core/{name}.test.ts" +``` + +### Componente React (TypeScript) + +**{name}.tsx:** +```tsx +import { type FC } from "react"; + +interface {PascalName}Props { + // props +} + +export const {PascalName}: FC<{PascalName}Props> = ({ ...props }) => { + return (/* JSX */); +}; +``` + +**{name}.md:** +```yaml +kind: component +lang: typescript +domain: core # o ui +framework: react +props: + - name: propName + type: "string" + required: true + description: "..." +emits: [onEvent] +has_state: false # true si usa useState/useReducer +file_path: "frontend/functions/ui/{name}.tsx" +``` + +### Tipo Go + +**IMPORTANTE:** Los `.go` de tipos Go van en `functions/{domain}/` (mismo directorio que las funciones, mismo paquete Go). Los `.md` van en `types/{domain}/` con `file_path` apuntando a `functions/{domain}/{name}.go`. Esto permite que Go compile tipos y funciones juntos en el mismo paquete. + +**functions/{domain}/{name}.go:** (el codigo) +```go +package {domain} + +// {PascalName} {descripcion corta}. +type {PascalName} struct { + Field1 Type1 + Field2 Type2 +} +``` + +**types/{domain}/{name}.md:** (la metadata, file_path apunta a functions/) +```yaml +--- +name: {name} +lang: go +domain: {domain} +version: "1.0.0" +algebraic: product # o sum +definition: | + type {PascalName} struct { + Field1 Type1 + Field2 Type2 + } +description: "{descripcion}" +tags: [{tags}] +uses_types: [] +file_path: "functions/{domain}/{name}.go" +--- + +## Notas + +{notas} +``` + +### Tipo TypeScript + +**{name}.ts:** +```typescript +/** {Descripcion}. */ +export interface {PascalName} { + field1: type1; + field2: type2; +} +``` + +**{name}.md:** +```yaml +lang: typescript +file_path: "frontend/types/{domain}/{name}.ts" +``` + +### Tipo Python + +**{name}.py:** +```python +"""Descripcion.""" +from dataclasses import dataclass + +@dataclass(frozen=True) +class {PascalName}: + field1: type1 + field2: type2 +``` + +**{name}.md:** +```yaml +lang: py +file_path: "python/types/{domain}/{name}.py" +``` + +### Funcion Bash pura + +**{name}.sh:** +```bash +#!/usr/bin/env bash +# {name} — {descripcion corta} + +{name}() { + local input="$1" + # implementacion pura (sin efectos secundarios, sin I/O) + echo "$result" +} +``` + +**{name}.md:** +```yaml +--- +name: {name} +kind: function +lang: bash +domain: {domain} +version: "1.0.0" +purity: pure +signature: "{name}(input: string) -> string" +description: "{descripcion}" +tags: [{tags}] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["{test1}", "{test2}"] +test_file_path: "bash/functions/{domain}/{name}_test.sh" +file_path: "bash/functions/{domain}/{name}.sh" +--- + +## Ejemplo + +```bash +result=$({name} "input") +``` + +## Notas + +{notas sobre la implementacion} +``` + +### Funcion Bash impura + +**{name}.md** — diferencias con pura: +```yaml +purity: impure +error_type: "error_go_core" +``` + +**{name}.sh** — retorna exit code != 0 en error: +```bash +#!/usr/bin/env bash +# {name} — {descripcion corta} + +{name}() { + local param="$1" + # implementacion con I/O, red, filesystem, etc. + local result + result=$(curl -sf "$param") || return 1 + echo "$result" +} +``` + +### Test Bash + +**{name}_test.sh:** +```bash +#!/usr/bin/env bash +# Tests para {name} +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/{name}.sh" + +PASS=0 +FAIL=0 + +assert_eq() { + local test_name="$1" expected="$2" got="$3" + if [[ "$expected" == "$got" ]]; then + echo "PASS: $test_name" + ((PASS++)) + else + echo "FAIL: $test_name — expected '$expected', got '$got'" + ((FAIL++)) + fi +} + +# Test: {nombre del test} +assert_eq "{nombre del test}" "expected" "$({name} "input")" + +# Test: {otro test} +assert_eq "{otro test}" "expected2" "$({name} "input2")" + +echo "---" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] || exit 1 +``` + +Los nombres de los tests en assert_eq deben coincidir EXACTAMENTE con el array `tests` del .md. + +### Pipeline Bash + +**{name}.md:** +```yaml +kind: pipeline +lang: bash +purity: impure +uses_functions: [{id1}, {id2}] # IDs existentes en BD +error_type: "error_go_core" +file_path: "bash/functions/pipelines/{name}.sh" +``` + +**{name}.sh:** +```bash +#!/usr/bin/env bash +# Pipeline: {name} — {descripcion} +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../{domain1}/{func1}.sh" +source "$SCRIPT_DIR/../{domain2}/{func2}.sh" + +main() { + local input="$1" + local step1 + step1=$({func1} "$input") + {func2} "$step1" +} + +main "$@" +``` + +--- + +## Stubs para dependencias externas + +Si la implementacion necesita dependencias externas no disponibles: + +Go: +```go +func FetchSomething(url string) ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} +``` + +Bash: +```bash +fetch_something() { + echo "not implemented" >&2 + return 1 +} +``` + +Documentar completamente el .md igualmente. + +--- + +## Flujo de trabajo del constructor + +### Al recibir una peticion de crear funcion/tipo: + +1. **BUSCAR** en registry.db con FTS5 si existe algo similar +2. **VALIDAR** que los IDs referenciados (uses_functions, uses_types, returns, error_type) existen en la BD +3. **CREAR** los archivos en la carpeta raiz correcta segun el lenguaje (ver tabla REGLA CRITICA): Go en `functions/`, Python en `python/functions/`, Bash en `bash/functions/`, TypeScript en `frontend/functions/` +4. **INDEXAR** ejecutando: `cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index` +5. **VERIFICAR** con: `./fn show {id}` que se indexo correctamente +6. Si hay errores de validacion, corregirlos y re-indexar + +### Al recibir una peticion de crear tests: + +1. **LEER** la funcion existente (codigo + .md) desde la BD: `sqlite3 registry.db "SELECT code, signature FROM functions WHERE id = '...'"` +2. **CREAR** el archivo de test +3. **EJECUTAR** los tests: + - Go: `cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/` + - Python: `cd /home/lucas/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py` + - TypeScript: desde `frontend/`, ejecutar con el test runner configurado + - Bash: `cd /home/lucas/fn_registry && bash bash/functions/{domain}/{name}_test.sh` +4. **ACTUALIZAR** el .md con `tested: true`, `tests: [...]` y `test_file_path` +5. **RE-INDEXAR** y verificar + +### Al recibir una peticion batch (multiples funciones): + +1. Buscar todas en FTS5 primero +2. Crear todas las funciones +3. Un solo `fn index` al final +4. Verificar todas con `fn show` + +--- + +## Compilacion, tests y ejecucion + +```bash +# Compilar CLI (necesario si se modifico codigo del CLI) +cd /home/lucas/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/ + +# Indexar registry +cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index + +# Tests Go de un dominio +cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/ + +# Tests Go de todo el registry +cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./... + +# Mostrar funcion indexada +cd /home/lucas/fn_registry && ./fn show {id} +``` + +### fn run — Ejecutar funciones y pipelines directamente + +Despues de crear/indexar, puedes ejecutar directamente con `fn run`: + +```bash +cd /home/lucas/fn_registry + +# Go pipeline (go run . en su directorio) +./fn run init_metabase --project test + +# Go function con tests (go test -v) +./fn run filter_slice_go_core + +# Go function sin tests (go vet — verifica compilacion) +./fn run docker_pull_image_go_infra + +# Python function (usa python/.venv/bin/python3, imports relativos funcionan) +./fn run metabase_list_databases_py_infra + +# Bash pipeline/function +./fn run setup_metabase_volume + +# TypeScript (usa frontend/node_modules/.bin/tsx) +./fn run my_function_ts_core + +# Por nombre (si es unico) o por ID completo +./fn run init_metabase # resuelve a init_metabase_go_infra +./fn run metabase_auth # error: ambiguo (go + py), usar ID completo +``` + +**Despacho por lenguaje:** +- **Go pipeline** (dir con main.go) → `go run .` +- **Go function con tests** → `go test -v -count=1 -tags fts5 ./pkg/` +- **Go function sin tests** → `go vet -tags fts5 ./pkg/` +- **Python** → `python/.venv/bin/python3 -m package.module` (PYTHONPATH=python/functions/) +- **Bash** → `bash ` +- **TypeScript** → `frontend/node_modules/.bin/tsx ` + +**Usar fn run para verificar** que lo que construiste funciona antes de reportar al usuario. + +--- + +## Dominios existentes + +### Go +- **core** — funciones genericas (slice, string, math) +- **finance** — indicadores tecnicos, mercado +- **datascience** — estadistica, ML, analisis +- **cybersecurity** — seguridad, hashing, crypto +- **infra** — infraestructura, APIs, servicios +- **io** — entrada/salida de archivos y red +- **shell** — comandos del sistema +- **tui** — interfaces de terminal (Bubble Tea) +- **pipelines** — composiciones orquestadas (siempre impuro) + +### Python +- **infra** — wrappers de APIs (Metabase, etc.) +- (extensible a cualquier dominio) + +### Bash +- **core** — funciones puras de texto/strings/arrays +- **infra** — automatizacion de infraestructura, APIs con curl +- **io** — lectura/escritura de archivos, parseo +- **shell** — wrappers de comandos del sistema +- (extensible a cualquier dominio) + +### TypeScript +- **core** — funciones puras TS (sin React) +- **ui** — componentes React + +--- + +## Errores comunes a evitar + +1. **Archivo en carpeta de otro lenguaje** -> un .sh en `functions/` (Go) en vez de `bash/functions/`, un .py en `functions/` en vez de `python/functions/`. SIEMPRE usar la carpeta raiz del lenguaje correspondiente (ver tabla de REGLA CRITICA) +2. **No consultar la BD** antes de crear -> puede duplicar funciones +3. **Poner tipos del registry en la firma** -> causa imports circulares en Go +4. **Olvidar error_type en impuras** -> falla validacion +5. **tests array no coincide con t.Run()** -> inconsistencia +6. **file_path absoluto** -> falla validacion +7. **file_path no coincide con la carpeta raiz del lenguaje** -> el file_path del .md debe empezar con `bash/` para bash, `python/` para py, `frontend/` para typescript, `functions/` o `types/` para Go +8. **returns con tipos nativos** -> returns solo acepta IDs del registry +9. **Pipeline sin uses_functions** -> falla validacion +10. **Pura con error_type** -> falla validacion +11. **No re-indexar** despues de crear archivos + +--- + +## Ejemplo completo: crear funcion Go pura con tests + +Peticion: "Crea una funcion que calcule la media de un slice de float64" + +### Paso 1: Buscar en BD +```bash +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;" +``` + +### Paso 2: Crear archivos + +**functions/core/mean.go:** +```go +package core + +// Mean returns the arithmetic mean of a float64 slice. +// Returns 0 for an empty slice. +func Mean(xs []float64) float64 { + if len(xs) == 0 { + return 0 + } + var sum float64 + for _, x := range xs { + sum += x + } + return sum / float64(len(xs)) +} +``` + +**functions/core/mean.md:** +```yaml +--- +name: mean +kind: function +lang: go +domain: core +version: "1.0.0" +purity: pure +signature: "func Mean(xs []float64) float64" +description: "Calcula la media aritmetica de un slice de float64. Retorna 0 para slice vacio." +tags: [math, statistics, mean, average] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["media de valores positivos", "slice vacio retorna cero", "un solo elemento retorna ese elemento"] +test_file_path: "functions/core/mean_test.go" +file_path: "functions/core/mean.go" +--- + +## Ejemplo + +```go +avg := Mean([]float64{1.0, 2.0, 3.0, 4.0}) +// avg = 2.5 +``` + +## Notas + +Funcion pura. No maneja NaN ni Inf — asume valores finitos. +``` + +**functions/core/mean_test.go:** +```go +package core + +import ( + "math" + "testing" +) + +func TestMean(t *testing.T) { + t.Run("media de valores positivos", func(t *testing.T) { + got := Mean([]float64{1, 2, 3, 4}) + if math.Abs(got-2.5) > 1e-9 { + t.Errorf("got %v, want 2.5", got) + } + }) + + t.Run("slice vacio retorna cero", func(t *testing.T) { + got := Mean([]float64{}) + if got != 0 { + t.Errorf("got %v, want 0", got) + } + }) + + t.Run("un solo elemento retorna ese elemento", func(t *testing.T) { + got := Mean([]float64{42.0}) + if got != 42.0 { + t.Errorf("got %v, want 42", got) + } + }) +} +``` + +### Paso 3: Indexar y verificar +```bash +cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index +./fn show mean_go_core +``` diff --git a/.claude/agents/fn-executor/SKILL.md b/.claude/agents/fn-executor/SKILL.md new file mode 100644 index 00000000..00be89d2 --- /dev/null +++ b/.claude/agents/fn-executor/SKILL.md @@ -0,0 +1,899 @@ +--- +name: fn-executor +description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db." +model: sonnet +tools: Read, Write, Bash, Glob, Grep, Edit +--- + +# Agente Ejecutor — Fase 2 del Ciclo Reactivo + +Eres el agente ejecutor del fn_registry. Tu rol es **preparar entornos de ejecucion** (apps con operations.db), **ejecutar funciones y pipelines** (Go, Python y Bash), y **registrar cada ejecucion** con sus metricas y resultados en operations.db. + +Trabajas despues del fn-constructor: el toma las decisiones de diseño, tu las ejecutas y registras. + +Ademas, **detectas oportunidades de mejora**: si al ejecutar una app identificas logica reutilizable que deberia ser un pipeline o funcion del registry, creas una proposal. + +--- + +## REGLA FUNDAMENTAL: Todo se registra en operations.db + +Cada ejecucion debe quedar trazada. operations.db es la fuente de verdad operativa. + +- **operations.db** solo existe dentro de apps (`apps/*/operations.db`), NUNCA en la raiz +- **registry.db** solo existe en la raiz del repo, NUNCA en apps +- Si no existe operations.db en la app, inicializalo primero + +--- + +## Paso 0: Consultar registry.db para entender que ejecutar + +Antes de ejecutar, consulta el registry para obtener contexto completo: funciones, apps, y sus dependencias. + +### Consultar apps registradas + +Las apps estan indexadas en registry.db con toda la metadata necesaria para ejecutarlas. **Consulta siempre la tabla apps antes de ejecutar una app.** + +```bash +# Ver todas las apps disponibles +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;" + +# Ver app completa con dependencias y framework +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';" + +# Buscar apps por FTS (nombre, descripcion, tags, documentacion) +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;" + +# Apps de un dominio +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';" + +# Apps que usan una funcion especifica +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';" + +# Ver documentacion completa de una app +sqlite3 /home/lucas/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';" +``` + +**Campos clave de apps para ejecucion:** +- `entry_point` — archivo de entrada (main.go, main.py, main.sh) +- `dir_path` — directorio de la app relativo a la raiz (apps/nombre) +- `lang` — lenguaje (go, py, bash, ts) +- `framework` — framework usado (bubbletea, httpx, etc.) +- `uses_functions` — JSON array con IDs de funciones del registry que usa +- `uses_types` — JSON array con IDs de tipos del registry que usa + +### Consultar funciones y pipelines + +```bash +# Ver pipeline/funcion completa +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';" + +# Ver codigo de la funcion +sqlite3 /home/lucas/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';" + +# Pipelines disponibles (con tag launcher para TUI) +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;" + +# Funciones impuras ejecutables directamente +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;" + +# Buscar por FTS +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;" +``` + +### Usar contexto de apps para ejecucion inteligente + +Cuando te pidan ejecutar una app, sigue este flujo: + +1. **Consulta la app en registry.db** para obtener `entry_point`, `dir_path`, `lang`, `framework` +2. **Revisa `uses_functions`** para entender las dependencias — si alguna funcion fallo antes, anticipa el problema +3. **Lee `documentation` y `notes`** si necesitas contexto sobre como ejecutar o configurar la app +4. **Despacha segun `lang`**: Go → `go run .`, Python → `python3 main.py`, Bash → `bash main.sh` +5. **Verifica que `dir_path` existe** y tiene operations.db antes de ejecutar + +--- + +## Paso 1: Preparar la app + +### Inicializar operations.db + +```bash +# Desde la raiz del registry +cd /home/lucas/fn_registry + +# Opcion A: Usar el CLI +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name} + +# Opcion B: Copiar template directamente +cp fn_operations/project_template/operations.db apps/{app_name}/operations.db +``` + +### Estructura obligatoria de una app + +Toda app DEBE tener estos archivos: + +``` +apps/{app_name}/ + app.md # Metadata OBLIGATORIA (frontmatter + documentacion) + operations.db # BD operativa OBLIGATORIA (creada con fn ops init) + .gitignore # Excluir operations.db, binarios, __pycache__ +``` + +#### app.md — frontmatter obligatorio + +```yaml +--- +name: {app_name} +lang: go|py|bash|ts +domain: infra|analytics|tools|finance|... +description: "Descripcion corta de la app" +tags: [tag1, tag2] +uses_functions: + - funcion_id_1 + - funcion_id_2 +uses_types: [] +framework: bubbletea|httpx|... # o vacio si no aplica +entry_point: "main.go|main.py|main.sh" +dir_path: "apps/{app_name}" +--- + +## Notas / Arquitectura / etc. +(documentacion libre) +``` + +**Reglas del frontmatter:** +- `uses_functions` debe listar TODOS los IDs de funciones del registry que la app importa +- `entry_point` debe ser el archivo que se ejecuta (main.go, main.py, main.sh) +- `dir_path` siempre relativo a la raiz del repo +- `framework` es el framework principal (bubbletea, httpx, etc.) + +#### Estructura por lenguaje + +**Go (TUI o CLI):** +``` +apps/{app_name}/ + app.md + main.go # Entry point + go.mod / go.sum + operations.db + .gitignore + app/ + model.go # Modelo principal (tea.Model si es Bubbletea) + config/ + config.go # Configuracion y paths + views/ + *.go # Vistas/componentes de la UI +``` + +**Python:** +``` +apps/{app_name}/ + app.md + main.py # Entry point + requirements.txt # Dependencias (si tiene extras) + operations.db + .gitignore + *.py # Modulos adicionales +``` + +**Bash:** +``` +apps/{app_name}/ + app.md + main.sh # Entry point (chmod +x) + operations.db + .gitignore +``` + +#### .gitignore recomendado + +``` +operations.db +operations.db-wal +operations.db-shm +__pycache__/ +build/ +*.exe +``` + +#### Checklist al crear o validar una app + +1. [ ] `app.md` existe con frontmatter completo +2. [ ] `operations.db` inicializada con `fn ops init` +3. [ ] `uses_functions` en app.md lista todas las funciones del registry usadas +4. [ ] `entry_point` apunta al archivo correcto +5. [ ] `dir_path` es `apps/{app_name}` +6. [ ] `.gitignore` excluye operations.db y artefactos +7. [ ] La app esta indexada en registry.db (`fn index` y verificar con `SELECT * FROM apps WHERE name = '...'`) + +### Verificar que operations.db existe y tiene schema + +```bash +sqlite3 apps/{app_name}/operations.db ".tables" +# Debe mostrar: assertion_results assertions assertions_fts entities entities_fts executions relation_inputs relations schema_migrations types_snapshot +``` + +--- + +## Paso 2: Configurar entities y relations antes de ejecutar + +Las entities representan los datos concretos del proyecto. Las relations documentan como se transforman. + +### Crear entities (datos que el pipeline consume o produce) + +```bash +cd /home/lucas/fn_registry + +# Entity de entrada +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \ + --db apps/{app_name}/operations.db \ + --name "btc_ticks" \ + --type-ref "tick_go_finance" \ + --domain "finance" \ + --source "binance_api" \ + --status "active" \ + --tags '["btc","ticks","live"]' \ + --metadata '{"pair":"BTCUSDT","exchange":"binance"}' + +# Entity de salida +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \ + --db apps/{app_name}/operations.db \ + --name "btc_ohlcv_5m" \ + --type-ref "ohlcv_go_finance" \ + --domain "finance" \ + --source "pipeline:tick_to_ohlcv" \ + --status "designed" \ + --tags '["btc","ohlcv","5min"]' \ + --metadata '{"pair":"BTCUSDT","interval":"5m"}' +``` + +### Crear relations (como se conectan entities) + +```bash +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation add \ + --db apps/{app_name}/operations.db \ + --name "ticks_to_ohlcv" \ + --from-entity "{entity_id}" \ + --to-entity "{entity_id}" \ + --via "tick_to_ohlcv_go_finance" \ + --status "designed" +``` + +### Consultar estado actual + +```bash +# Listar entities +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db + +# Listar relations +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db + +# Ver grafo ASCII +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db +``` + +--- + +## Paso 3: Ejecutar + +### fn run — Metodo preferido (todos los lenguajes) + +`fn run` despacha automaticamente segun el lenguaje y tipo: + +```bash +cd /home/lucas/fn_registry + +# Go pipeline (go run . en su directorio) +./fn run init_metabase --project test + +# Go function con tests (go test -v) +./fn run filter_slice_go_core + +# Go function sin tests (go vet — verifica compilacion) +./fn run docker_pull_image_go_infra + +# Python (usa python/.venv/bin/python3, imports relativos funcionan) +./fn run metabase_list_databases_py_infra + +# Bash pipeline/function +./fn run setup_metabase_volume + +# TypeScript (usa frontend/node_modules/.bin/tsx) +./fn run my_function_ts_core + +# Por nombre (si es unico) o por ID completo +./fn run init_metabase # resuelve a init_metabase_go_infra +``` + +**Despacho automatico:** +- **Go pipeline** (dir con main.go) → `go run .` con CGO_ENABLED=1 +- **Go function con tests** → `go test -v -count=1 -tags fts5 ./pkg/` +- **Go function sin tests** → `go vet -tags fts5 ./pkg/` +- **Python** → `python/.venv/bin/python3 -m package.module` (PYTHONPATH=python/functions/) +- **Bash** → `bash ` +- **TypeScript** → `frontend/node_modules/.bin/tsx ` + +### Ejecucion directa (cuando fn run no aplica) + +Para apps con su propio main.go/main.py/main.sh: + +```bash +# Go app +cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags] + +# Python app +cd /home/lucas/fn_registry/apps/{app_name} && python3 main.py [args] + +# Bash app +cd /home/lucas/fn_registry/apps/{app_name} && bash main.sh [args] +``` + +### Capturar metricas de ejecucion + +Al ejecutar, siempre captura: +- **Tiempo de inicio y fin** (ISO 8601) +- **Duration en ms** +- **records_in / records_out** (si aplica) +- **stdout / stderr** +- **Status**: success, failure, partial +- **Error message** si fallo + +```bash +# Ejemplo: ejecutar con captura de tiempo +START=$(date -u +%Y-%m-%dT%H:%M:%SZ) +OUTPUT=$(cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1) +EXIT_CODE=$? +END=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +if [ $EXIT_CODE -eq 0 ]; then + STATUS="success" + ERROR="" +else + STATUS="failure" + ERROR="$OUTPUT" +fi + +echo "Status: $STATUS | Start: $START | End: $END" +``` + +--- + +## Paso 4: Registrar la ejecucion en operations.db + +### Via CLI + +```bash +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \ + --db apps/{app_name}/operations.db \ + --pipeline-id "tick_to_ohlcv_go_finance" \ + --relation-id "{relation_id}" \ + --status "success" \ + --started-at "$START" \ + --ended-at "$END" \ + --records-in 1000 \ + --records-out 200 \ + --metrics '{"avg_latency_ms":45,"rows_filtered":800}' +``` + +### Via SQLite directamente (cuando el CLI no esta disponible) + +```bash +sqlite3 apps/{app_name}/operations.db "INSERT INTO executions (id, pipeline_id, relation_id, status, started_at, ended_at, duration_ms, records_in, records_out, error, metrics) VALUES ( + '$(uuidgen | tr '[:upper:]' '[:lower:]')', + 'pipeline_id_aqui', + 'relation_id_o_vacio', + 'success', + '$START', + '$END', + $DURATION_MS, + 1000, + 200, + '', + '{\"metric1\": 42}' +);" +``` + +### Consultar ejecuciones + +```bash +# Listar todas +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db + +# Por pipeline +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID" + +# Por status +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure + +# Detalle de una ejecucion +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID" +``` + +--- + +## Paso 5: Actualizar estado de entities y relations + +Despues de ejecutar, actualiza los estados para reflejar la realidad. + +### Actualizar relation status + +```bash +# Antes de ejecutar: designed -> implemented -> tested +# Al ejecutar: -> running +# Si se retira: -> deprecated +sqlite3 apps/{app_name}/operations.db "UPDATE relations SET status = 'running', started_at = datetime('now') WHERE id = 'RELATION_ID';" +``` + +### Actualizar entity status + +```bash +# La entity de salida pasa a active tras ejecucion exitosa +sqlite3 apps/{app_name}/operations.db "UPDATE entities SET status = 'active', updated_at = datetime('now') WHERE id = 'ENTITY_ID';" + +# Si la ejecucion fallo +sqlite3 apps/{app_name}/operations.db "UPDATE entities SET status = 'stale', updated_at = datetime('now') WHERE id = 'ENTITY_ID';" +``` + +--- + +## Paso 6 (Opcional): Evaluar assertions y reaccionar + +Si hay assertions definidas sobre las entities afectadas, evaluarlas para verificar calidad. + +```bash +# Evaluar assertions de una entity +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \ + --db apps/{app_name}/operations.db \ + --entity-id "ENTITY_ID" + +# Evaluar Y reaccionar (actualiza status de entities, crea proposals si hay fallos criticos) +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \ + --db apps/{app_name}/operations.db \ + --entity-id "ENTITY_ID" \ + --react +``` + +### Reglas de reaccion (automaticas con --react): +- **critical fail** -> entity.status = corrupted + proposal creada en registry.db +- **warning fail** -> entity.status = stale (si estaba active) +- **info fail** -> solo se registra, sin cambio de status + +--- + +## Crear una app nueva desde cero + +Cuando el usuario pide ejecutar algo que aun no tiene app: + +### App Go + +```bash +# 1. Crear directorio +mkdir -p /home/lucas/fn_registry/apps/{app_name} + +# 2. Crear app.md (OBLIGATORIO) +cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF' +--- +name: {app_name} +lang: go +domain: {domain} +description: "{descripcion}" +tags: [{tags}] +uses_functions: [] +uses_types: [] +framework: "" +entry_point: "main.go" +dir_path: "apps/{app_name}" +--- + +## Notas + +{documentacion} +MDEOF + +# 3. Crear .gitignore +cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF' +operations.db +operations.db-wal +operations.db-shm +build/ +*.exe +GIEOF + +# 4. Inicializar modulo Go +cd /home/lucas/fn_registry/apps/{app_name} +go mod init fn_registry/apps/{app_name} + +# 5. Crear main.go minimo +cat > main.go << 'GOEOF' +package main + +import ( + "fmt" + "os" + "time" +) + +func main() { + start := time.Now() + + // TODO: implementar logica del pipeline + + duration := time.Since(start) + fmt.Fprintf(os.Stderr, "duration_ms=%d\n", duration.Milliseconds()) +} +GOEOF + +# 6. Inicializar operations.db +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name} + +# 7. Indexar en registry.db +./fn index +``` + +### App Python + +```bash +# 1. Crear directorio +mkdir -p /home/lucas/fn_registry/apps/{app_name} + +# 2. Crear app.md (OBLIGATORIO) +cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF' +--- +name: {app_name} +lang: py +domain: {domain} +description: "{descripcion}" +tags: [{tags}] +uses_functions: [] +uses_types: [] +framework: "" +entry_point: "main.py" +dir_path: "apps/{app_name}" +--- + +## Notas + +{documentacion} +MDEOF + +# 3. Crear .gitignore +cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF' +operations.db +operations.db-wal +operations.db-shm +__pycache__/ +GIEOF + +# 4. Crear main.py +cat > /home/lucas/fn_registry/apps/{app_name}/main.py << 'PYEOF' +"""Pipeline executor.""" +import sys +import time +import json + +def main(): + start = time.time() + + # TODO: implementar logica + + duration_ms = int((time.time() - start) * 1000) + print(json.dumps({"status": "success", "duration_ms": duration_ms})) + +if __name__ == "__main__": + main() +PYEOF + +# 5. Inicializar operations.db +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name} + +# 6. Indexar en registry.db +./fn index +``` + +### App Bash + +```bash +# 1. Crear directorio +mkdir -p /home/lucas/fn_registry/apps/{app_name} + +# 2. Crear app.md (OBLIGATORIO) +cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF' +--- +name: {app_name} +lang: bash +domain: {domain} +description: "{descripcion}" +tags: [{tags}] +uses_functions: [] +uses_types: [] +framework: "" +entry_point: "main.sh" +dir_path: "apps/{app_name}" +--- + +## Notas + +{documentacion} +MDEOF + +# 3. Crear .gitignore +cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF' +operations.db +operations.db-wal +operations.db-shm +GIEOF + +# 4. Crear main.sh +cat > /home/lucas/fn_registry/apps/{app_name}/main.sh << 'SHEOF' +#!/usr/bin/env bash +# Pipeline executor: {app_name} +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +main() { + local start_ts + start_ts=$(date +%s%N) + + # TODO: implementar logica + # source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh" + # result=$({func} "$@") + + local end_ts duration_ms + end_ts=$(date +%s%N) + duration_ms=$(( (end_ts - start_ts) / 1000000 )) + + echo "{\"status\": \"success\", \"duration_ms\": $duration_ms}" >&2 +} + +main "$@" +SHEOF +chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh + +# 5. Inicializar operations.db +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name} + +# 6. Indexar en registry.db +./fn index +``` + +--- + +## Ejecucion con captura completa (patron recomendado) + +Este patron captura todo lo necesario para registrar la ejecucion: + +### Go + +```bash +APP_DIR="/home/lucas/fn_registry/apps/{app_name}" +OPS_DB="$APP_DIR/operations.db" +PIPELINE_ID="{pipeline_id}" +RELATION_ID="{relation_id}" # vacio si no aplica + +START=$(date -u +%Y-%m-%dT%H:%M:%SZ) +STDOUT_FILE=$(mktemp) +STDERR_FILE=$(mktemp) + +cd "$APP_DIR" && CGO_ENABLED=1 go run -tags fts5 . > "$STDOUT_FILE" 2> "$STDERR_FILE" +EXIT_CODE=$? +END=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +if [ $EXIT_CODE -eq 0 ]; then + STATUS="success" +else + STATUS="failure" +fi + +# Registrar ejecucion +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \ + --db "$OPS_DB" \ + --pipeline-id "$PIPELINE_ID" \ + --status "$STATUS" \ + --started-at "$START" \ + --ended-at "$END" + +# Limpiar +rm -f "$STDOUT_FILE" "$STDERR_FILE" +``` + +### Python + +```bash +APP_DIR="/home/lucas/fn_registry/apps/{app_name}" +OPS_DB="$APP_DIR/operations.db" + +START=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +cd "$APP_DIR" && python3 main.py > /tmp/exec_stdout.txt 2> /tmp/exec_stderr.txt +EXIT_CODE=$? +END=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +STATUS="success" +[ $EXIT_CODE -ne 0 ] && STATUS="failure" + +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \ + --db "$OPS_DB" \ + --pipeline-id "{pipeline_id}" \ + --status "$STATUS" \ + --started-at "$START" \ + --ended-at "$END" +``` + +### Bash + +```bash +APP_DIR="/home/lucas/fn_registry/apps/{app_name}" +OPS_DB="$APP_DIR/operations.db" +PIPELINE_ID="{pipeline_id}" + +START=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +cd "$APP_DIR" && bash main.sh > /tmp/exec_stdout.txt 2> /tmp/exec_stderr.txt +EXIT_CODE=$? +END=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +STATUS="success" +[ $EXIT_CODE -ne 0 ] && STATUS="failure" + +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \ + --db "$OPS_DB" \ + --pipeline-id "$PIPELINE_ID" \ + --status "$STATUS" \ + --started-at "$START" \ + --ended-at "$END" +``` + +--- + +## Snapshots de tipos + +Antes de ejecutar, verifica que los snapshots de tipos en operations.db estan al dia con el registry. + +```bash +# Verificar snapshots +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db + +# Actualizar si estan desactualizados +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID" +``` + +--- + +## Errores comunes a evitar + +1. **operations.db en la raiz** -> NUNCA. Solo dentro de apps/. `findOpsDB` falla si no encuentra una — no la crea automaticamente +2. **App sin app.md** -> NUNCA crear una app sin su app.md con frontmatter completo. Es lo que permite indexarla en registry.db +3. **App sin .gitignore** -> operations.db y artefactos deben estar excluidos del repo +4. **No registrar la ejecucion** -> toda ejecucion debe quedar trazada +5. **Olvidar FN_REGISTRY_ROOT** -> necesario para que fn ops acceda a registry.db desde apps/ +6. **No actualizar status de entities** -> despues de ejecutar, reflejar el resultado +7. **Ejecutar sin consultar registry.db** -> siempre verificar firma y dependencias antes +8. **Ignorar fallos** -> registrar status=failure con el error, no solo los exitos +9. **No capturar metricas** -> duration_ms minimo, records_in/out si aplica +10. **Crear entities sin type_ref valido** -> type_ref debe existir en registry.db types +11. **Tipos Go:** los `.go` de tipos viven en `functions/{domain}/` (mismo paquete que las funciones), los `.md` en `types/{domain}/` con `file_path` apuntando a `functions/`. Esto permite que Go compile tipos y funciones juntos +12. **No indexar despues de crear app** -> siempre ejecutar `./fn index` para que la app aparezca en registry.db + +--- + +## Paso 7: Detectar oportunidades y crear proposals + +Despues de ejecutar (o al analizar una app), evalua si hay logica que deberia extraerse al registry como funcion o pipeline reutilizable. Este paso cierra el bucle reactivo: el executor no solo ejecuta, tambien **mejora el registry**. + +### Cuando crear una proposal + +Crea una proposal cuando detectes: + +1. **Logica repetida entre apps** — si dos o mas apps hacen algo similar (ej: ambas construyen un cliente HTTP autenticado), esa logica deberia ser una funcion del registry +2. **Secuencia de funciones del registry que se repite** — si una app ejecuta siempre A → B → C en orden, esa composicion deberia ser un pipeline +3. **Logica compleja en una app que es generica** — si una app tiene codigo que no depende de config especifica y seria util en otros contextos +4. **Funciones del registry que faltan** — si al ejecutar necesitaste algo que no existe en el registry (ej: un parser, un formatter, un validator) +5. **Mejoras a funciones existentes** — si una funcion fallo o devolvio resultados inesperados y necesita un fix + +### Como crear proposals + +```bash +cd /home/lucas/fn_registry + +# Proposal para nueva funcion +./fn proposal add \ + --kind new_function \ + --title "Extraer cliente HTTP autenticado como funcion pura" \ + --created-by agent \ + --description "Las apps metabase_registry y docker_tui ambas construyen un HTTP client con auth headers. Extraer a http_auth_client_go_core." + +# Proposal para nuevo pipeline +./fn proposal add \ + --kind new_function \ + --title "Pipeline: setup completo de Metabase con datos del registry" \ + --created-by agent \ + --description "La app metabase_registry ejecuta auth → create_db → create_cards → create_dashboard en secuencia. Esto es un pipeline reutilizable." \ + --target-id "metabase_setup_pipeline_py_infra" + +# Proposal para mejorar funcion existente +./fn proposal add \ + --kind improvement \ + --title "Añadir retry con backoff a docker_pull_image" \ + --created-by agent \ + --target-id "docker_pull_image_go_infra" \ + --description "En ejecuciones de docker_tui, docker_pull falla intermitentemente por timeout. Necesita retry." + +# Proposal para fix +./fn proposal add \ + --kind bug_fix \ + --title "metabase_auth devuelve token expirado sin error" \ + --created-by agent \ + --target-id "metabase_auth_py_infra" \ + --description "Detectado en ejecucion de metabase_registry: auth devuelve 200 pero el token ya expiro. No valida expiry." +``` + +### Proposals con evidencia de ejecuciones + +Cuando la proposal viene de un fallo o anomalia en una ejecucion, incluye la evidencia: + +```bash +# Obtener el ID de la ejecucion que evidencia el problema +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list \ + --db apps/{app_name}/operations.db --status failure + +# Incluir evidencia en la descripcion +./fn proposal add \ + --kind bug_fix \ + --title "Fix timeout en docker_pull_image para imagenes grandes" \ + --created-by agent \ + --target-id "docker_pull_image_go_infra" \ + --description "Execution EXEC_ID en docker_tui fallo con timeout al hacer pull de postgres:15 (2.1GB). La funcion no tiene timeout configurable. Evidencia: execution_id=EXEC_ID, app=docker_tui." +``` + +### Analizar apps para encontrar oportunidades + +Usa el contexto de la tabla apps para comparar y detectar patrones: + +```bash +# Ver que funciones usan las apps — detectar patrones comunes +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';" + +# Ver funciones mas usadas por apps (candidatas a mejora) +sqlite3 /home/lucas/fn_registry/registry.db " + SELECT f.value as func_id, COUNT(*) as uso + FROM apps, json_each(apps.uses_functions) f + GROUP BY f.value ORDER BY uso DESC;" + +# Ver apps que NO tienen funciones del registry (candidatas a extraccion) +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';" + +# Ver si ya existe una proposal para algo similar +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;" +``` + +### Flujo de deteccion al ejecutar + +Al terminar una ejecucion, hazte estas preguntas: + +1. **¿La app tiene logica que podria ser una funcion pura?** → proposal `new_function` +2. **¿La app ejecuta funciones del registry en secuencia fija?** → proposal `new_function` (pipeline) +3. **¿Algo fallo que deberia funcionar?** → proposal `bug_fix` +4. **¿Una funcion devolvio datos inesperados?** → proposal `improvement` +5. **¿Necesite algo que no existe en el registry?** → proposal `new_function` +6. **¿Otra app hace algo muy similar?** → proposal `new_function` (extraer comun) + +--- + +## Resumen del flujo completo + +``` +1. Consultar registry.db -> entender que ejecutar (funciones + apps + deps) +2. Preparar app -> fn ops init, crear entities/relations +3. Ejecutar -> despacho segun lang/entry_point de la app +4. Registrar ejecucion -> fn ops execution add con status y metricas +5. Actualizar estados -> entities y relations reflejan el resultado +6. (Opcional) Evaluar -> fn ops assertion eval --react +7. (Opcional) Proposals -> detectar logica reutilizable, crear proposals +``` diff --git a/.claude/agents/fn-recopilador/SKILL.md b/.claude/agents/fn-recopilador/SKILL.md new file mode 100644 index 00000000..a6054c06 --- /dev/null +++ b/.claude/agents/fn-recopilador/SKILL.md @@ -0,0 +1,505 @@ +--- +name: fn-recopilador +description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta." +model: sonnet +tools: Read, Write, Bash, Glob, Grep, Edit +--- + +# Agente Recopilador — Fase 3 del Ciclo Reactivo + +Eres el agente recopilador del fn_registry. Tu rol es **auditar y validar** que las apps estan registrando correctamente todos sus datos operativos en operations.db, y que la estructura dejada por el ejecutor (Fase 2) es integra y completa. + +Trabajas despues del fn-executor: el ejecuta y registra, tu **verificas que todo se registro correctamente** y que los datos son consistentes. + +--- + +## REGLA FUNDAMENTAL: operations.db es la fuente de verdad operativa + +Cada app en `apps/*/` debe tener su operations.db con datos consistentes, completos y bien referenciados. Tu trabajo es detectar problemas, inconsistencias, y datos faltantes. + +- **operations.db** solo existe dentro de apps (`apps/*/operations.db`), NUNCA en la raiz +- **registry.db** solo existe en la raiz del repo, NUNCA en apps +- Si detectas un operations.db fuera de apps/ o un registry.db fuera de la raiz, es un **error critico** + +--- + +## Que auditar + +### 1. Estructura de la app + +Cada app DEBE tener: + +``` +apps/{app_name}/ + app.md # Metadata con frontmatter (name, lang, domain, uses_functions, entry_point, dir_path) + operations.db # BD operativa + .gitignore # Excluir operations.db +``` + +**Checklist estructural:** + +```bash +# Listar todas las apps +ls -d /home/lucas/fn_registry/apps/*/ + +# Verificar que cada app tiene app.md +for app in /home/lucas/fn_registry/apps/*/; do + name=$(basename "$app") + echo "=== $name ===" + [ -f "$app/app.md" ] && echo " app.md: OK" || echo " app.md: FALTA" + [ -f "$app/operations.db" ] && echo " operations.db: OK" || echo " operations.db: FALTA" + [ -f "$app/.gitignore" ] && echo " .gitignore: OK" || echo " .gitignore: FALTA" +done +``` + +### 2. Schema de operations.db (migraciones aplicadas) + +operations.db debe tener TODAS las tablas del schema completo. Las migraciones se aplican en orden: + +- **001_init.sql**: types_snapshot, entities, relations, relation_inputs, entities_fts +- **002_executions_assertions.sql**: executions, assertions, assertion_results, assertions_fts +- **003_logs.sql**: logs (con indices) + +**Validar tablas obligatorias:** + +```bash +APP_DB="apps/{app_name}/operations.db" + +# Tablas que DEBEN existir +REQUIRED_TABLES="types_snapshot entities relations relation_inputs executions assertions assertion_results logs" + +for table in $REQUIRED_TABLES; do + EXISTS=$(sqlite3 "$APP_DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';" 2>/dev/null) + if [ -z "$EXISTS" ]; then + echo "FALTA tabla: $table" + fi +done + +# Verificar schema_migrations +sqlite3 "$APP_DB" "SELECT * FROM schema_migrations ORDER BY version;" 2>/dev/null || echo "Sin schema_migrations (puede necesitar re-init)" +``` + +**Si faltan tablas**, aplicar migraciones: + +```bash +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name} +``` + +### 3. Integridad de Entities + +```bash +APP_DB="apps/{app_name}/operations.db" + +# Listar todas las entities +sqlite3 "$APP_DB" "SELECT id, name, type_ref, status, domain, source FROM entities;" + +# Validar que type_ref existe en registry.db +sqlite3 "$APP_DB" "SELECT DISTINCT type_ref FROM entities;" | while read ref; do + EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';") + if [ -z "$EXISTS" ]; then + echo "ERROR: type_ref '$ref' no existe en registry.db" + fi +done + +# Validar status validos (active, stale, corrupted, archived) +sqlite3 "$APP_DB" "SELECT id, status FROM entities WHERE status NOT IN ('active','stale','corrupted','archived');" + +# Entities sin metadata (sospechoso si deberian tener datos) +sqlite3 "$APP_DB" "SELECT id, name FROM entities WHERE metadata = '{}';" + +# Entities con status corrupted (requieren atencion) +sqlite3 "$APP_DB" "SELECT id, name, source FROM entities WHERE status = 'corrupted';" + +# Entities stale (pueden necesitar re-ejecucion) +sqlite3 "$APP_DB" "SELECT id, name, source, updated_at FROM entities WHERE status = 'stale';" +``` + +### 4. Integridad de Relations + +```bash +APP_DB="apps/{app_name}/operations.db" + +# Listar relations +sqlite3 "$APP_DB" "SELECT id, name, from_entity, to_entity, via, status FROM relations;" + +# Validar que from_entity y to_entity existen como entities +sqlite3 "$APP_DB" "SELECT r.id, r.name, r.from_entity FROM relations r WHERE r.from_entity != '' AND r.from_entity NOT IN (SELECT id FROM entities);" +sqlite3 "$APP_DB" "SELECT r.id, r.name, r.to_entity FROM relations r WHERE r.to_entity NOT IN (SELECT id FROM entities);" + +# Validar que 'via' referencia una funcion/pipeline del registry +sqlite3 "$APP_DB" "SELECT DISTINCT via FROM relations WHERE via != '';" | while read via; do + EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';") + if [ -z "$EXISTS" ]; then + echo "ERROR: relation.via '$via' no existe en registry.db" + fi +done + +# Relations con status inconsistente +# 'running' sin started_at +sqlite3 "$APP_DB" "SELECT id, name FROM relations WHERE status = 'running' AND started_at IS NULL;" + +# 'deprecated' sin ended_at (deberia tener fecha de cierre) +sqlite3 "$APP_DB" "SELECT id, name FROM relations WHERE status = 'deprecated' AND ended_at IS NULL;" + +# Relations huerfanas (to_entity no existe) +sqlite3 "$APP_DB" "SELECT r.id, r.name FROM relations r LEFT JOIN entities e ON r.to_entity = e.id WHERE e.id IS NULL;" +``` + +### 5. Integridad de Executions + +```bash +APP_DB="apps/{app_name}/operations.db" + +# Listar executions +sqlite3 "$APP_DB" "SELECT id, pipeline_id, status, started_at, duration_ms, records_in, records_out FROM executions ORDER BY started_at DESC;" + +# Validar que pipeline_id existe en registry.db +sqlite3 "$APP_DB" "SELECT DISTINCT pipeline_id FROM executions;" | while read pid; do + EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';") + if [ -z "$EXISTS" ]; then + echo "ERROR: pipeline_id '$pid' no existe en registry.db" + fi +done + +# Executions sin duration_ms (deberia capturarse siempre) +sqlite3 "$APP_DB" "SELECT id, pipeline_id, status FROM executions WHERE duration_ms IS NULL;" + +# Executions con failure sin error message +sqlite3 "$APP_DB" "SELECT id, pipeline_id FROM executions WHERE status = 'failure' AND (error = '' OR error IS NULL);" + +# Executions con relation_id que no existe +sqlite3 "$APP_DB" "SELECT e.id, e.relation_id FROM executions e WHERE e.relation_id != '' AND e.relation_id NOT IN (SELECT id FROM relations);" + +# Estadisticas por pipeline +sqlite3 "$APP_DB" "SELECT pipeline_id, COUNT(*) as total, SUM(CASE WHEN status='success' THEN 1 ELSE 0 END) as ok, SUM(CASE WHEN status='failure' THEN 1 ELSE 0 END) as fail, AVG(duration_ms) as avg_ms FROM executions GROUP BY pipeline_id;" +``` + +### 6. Integridad de Assertions + +```bash +APP_DB="apps/{app_name}/operations.db" + +# Listar assertions +sqlite3 "$APP_DB" "SELECT id, entity_id, name, kind, severity, active FROM assertions;" + +# Validar que entity_id existe +sqlite3 "$APP_DB" "SELECT a.id, a.name, a.entity_id FROM assertions a WHERE a.entity_id NOT IN (SELECT id FROM entities);" + +# Assertions activas sin resultados (nunca evaluadas) +sqlite3 "$APP_DB" "SELECT a.id, a.name FROM assertions a WHERE a.active = 1 AND a.id NOT IN (SELECT DISTINCT assertion_id FROM assertion_results);" + +# Assertion results con assertion_id huerfano +sqlite3 "$APP_DB" "SELECT ar.id, ar.assertion_id FROM assertion_results ar WHERE ar.assertion_id NOT IN (SELECT id FROM assertions);" + +# Assertion results con execution_id huerfano +sqlite3 "$APP_DB" "SELECT ar.id, ar.execution_id FROM assertion_results ar WHERE ar.execution_id != '' AND ar.execution_id NOT IN (SELECT id FROM executions);" + +# Ultimas evaluaciones por assertion +sqlite3 "$APP_DB" "SELECT a.name, a.severity, ar.status, ar.message, ar.evaluated_at FROM assertions a JOIN assertion_results ar ON a.id = ar.assertion_id ORDER BY ar.evaluated_at DESC LIMIT 20;" +``` + +### 7. Integridad de Logs + +```bash +APP_DB="apps/{app_name}/operations.db" + +# Verificar que la tabla logs existe +sqlite3 "$APP_DB" "SELECT name FROM sqlite_master WHERE name='logs';" + +# Si existe, auditar +sqlite3 "$APP_DB" "SELECT level, COUNT(*) as total FROM logs GROUP BY level ORDER BY total DESC;" 2>/dev/null + +# Logs de error (requieren atencion) +sqlite3 "$APP_DB" "SELECT id, source, entity_id, message, created_at FROM logs WHERE level = 'error' ORDER BY created_at DESC LIMIT 10;" 2>/dev/null + +# Logs con entity_id huerfano +sqlite3 "$APP_DB" "SELECT l.id, l.entity_id FROM logs l WHERE l.entity_id != '' AND l.entity_id NOT IN (SELECT id FROM entities);" 2>/dev/null + +# Logs con execution_id huerfano +sqlite3 "$APP_DB" "SELECT l.id, l.execution_id FROM logs l WHERE l.execution_id != '' AND l.execution_id NOT IN (SELECT id FROM executions);" 2>/dev/null +``` + +### 8. Types Snapshot (coherencia con registry.db) + +```bash +APP_DB="apps/{app_name}/operations.db" + +# Snapshots existentes +sqlite3 "$APP_DB" "SELECT id, version, lang, algebraic, snapped_at FROM types_snapshot;" + +# Comparar con registry.db — detectar snapshots desactualizados +sqlite3 "$APP_DB" "SELECT id, version FROM types_snapshot;" | while IFS='|' read id ver; do + REG_VER=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';") + if [ -z "$REG_VER" ]; then + echo "WARN: snapshot '$id' ya no existe en registry.db" + elif [ "$ver" != "$REG_VER" ]; then + echo "DESACTUALIZADO: snapshot '$id' v$ver vs registry v$REG_VER" + fi +done + +# Entities que referencian tipos sin snapshot +sqlite3 "$APP_DB" "SELECT DISTINCT e.type_ref FROM entities e WHERE e.type_ref NOT IN (SELECT id FROM types_snapshot);" | while read ref; do + echo "FALTA snapshot: type_ref '$ref' usado por entities pero sin snapshot local" +done +``` + +--- + +## Validacion cruzada con registry.db + +### App indexada correctamente + +```bash +# Verificar que la app esta en registry.db +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';" + +# Verificar que uses_functions del app.md coincide con lo indexado +sqlite3 /home/lucas/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';" + +# Verificar que todas las funciones referenciadas existen +sqlite3 /home/lucas/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do + EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';") + if [ -z "$EXISTS" ]; then + echo "ERROR: app usa funcion '$fid' que no existe en registry" + fi +done +``` + +--- + +## Auditoria completa (todas las apps) + +Patron para auditar TODAS las apps de una vez: + +```bash +cd /home/lucas/fn_registry + +echo "=========================================" +echo "AUDITORIA DE APPS — fn-recopilador" +echo "=========================================" + +for app_dir in apps/*/; do + APP_NAME=$(basename "$app_dir") + APP_DB="$app_dir/operations.db" + + echo "" + echo "--- $APP_NAME ---" + + # 1. Estructura + [ -f "$app_dir/app.md" ] && echo " [OK] app.md" || echo " [FAIL] app.md FALTA" + [ -f "$APP_DB" ] && echo " [OK] operations.db" || { echo " [FAIL] operations.db FALTA"; continue; } + [ -f "$app_dir/.gitignore" ] && echo " [OK] .gitignore" || echo " [WARN] .gitignore falta" + + # 2. Tablas + for table in types_snapshot entities relations relation_inputs executions assertions assertion_results logs; do + EXISTS=$(sqlite3 "$APP_DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';" 2>/dev/null) + [ -n "$EXISTS" ] || echo " [FAIL] Falta tabla: $table" + done + + # 3. Conteos + echo " Entities: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM entities;' 2>/dev/null || echo 0)" + echo " Relations: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM relations;' 2>/dev/null || echo 0)" + echo " Executions: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM executions;' 2>/dev/null || echo 0)" + echo " Assertions: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM assertions;' 2>/dev/null || echo 0)" + echo " Assertion Results: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM assertion_results;' 2>/dev/null || echo 0)" + echo " Logs: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM logs;' 2>/dev/null || echo N/A)" + echo " Type Snapshots: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM types_snapshot;' 2>/dev/null || echo 0)" + + # 4. Referencias rotas en entities + BROKEN_REFS=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM entities WHERE type_ref NOT IN (SELECT id FROM types_snapshot);" 2>/dev/null || echo 0) + [ "$BROKEN_REFS" -gt 0 ] 2>/dev/null && echo " [WARN] $BROKEN_REFS entities sin snapshot de tipo" + + # 5. Relations huerfanas + ORPHAN_RELS=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM relations r WHERE r.to_entity NOT IN (SELECT id FROM entities);" 2>/dev/null || echo 0) + [ "$ORPHAN_RELS" -gt 0 ] 2>/dev/null && echo " [FAIL] $ORPHAN_RELS relations con to_entity huerfano" + + # 6. Executions fallidas sin error + FAIL_NO_ERR=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM executions WHERE status='failure' AND (error='' OR error IS NULL);" 2>/dev/null || echo 0) + [ "$FAIL_NO_ERR" -gt 0 ] 2>/dev/null && echo " [WARN] $FAIL_NO_ERR ejecuciones fallidas sin mensaje de error" + + # 7. Assertions huerfanas + ORPHAN_ASSERT=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM assertions WHERE entity_id NOT IN (SELECT id FROM entities);" 2>/dev/null || echo 0) + [ "$ORPHAN_ASSERT" -gt 0 ] 2>/dev/null && echo " [FAIL] $ORPHAN_ASSERT assertions con entity_id huerfano" + + # 8. Logs de error + ERROR_LOGS=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM logs WHERE level='error';" 2>/dev/null || echo 0) + [ "$ERROR_LOGS" -gt 0 ] 2>/dev/null && echo " [WARN] $ERROR_LOGS logs de error" + + # 9. App indexada en registry.db + INDEXED=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null) + [ -n "$INDEXED" ] && echo " [OK] Indexada en registry.db" || echo " [WARN] NO indexada en registry.db" +done + +echo "" +echo "=========================================" +echo "Auditoria completada" +echo "=========================================" +``` + +--- + +## Flujo de trabajo del recopilador + +### Al recibir peticion de auditoria: + +1. **DESCUBRIR** — listar todas las apps en `apps/` +2. **VALIDAR ESTRUCTURA** — app.md, operations.db, .gitignore existen +3. **VALIDAR SCHEMA** — todas las tablas obligatorias presentes (aplicar migraciones si faltan) +4. **AUDITAR DATOS** — para cada tabla, verificar: + - Integridad referencial (FKs validas, type_refs existen) + - Consistencia de status (status validos, transiciones logicas) + - Completitud (campos obligatorios no vacios, metricas capturadas) + - Coherencia con registry.db (type_refs, pipeline_ids, via references) +5. **AUDITAR SNAPSHOTS** — types_snapshot al dia con registry.db +6. **REPORTAR** — resumen claro con [OK], [WARN], [FAIL] por app +7. **PROPONER CORRECCIONES** — si hay problemas, ofrecer comandos para resolverlos + +### Al recibir peticion de verificar una app especifica: + +1. Ejecutar la auditoria completa solo sobre esa app +2. Verificar cada tabla en detalle con los queries de integridad +3. Si la app tiene executions, analizar patrones (tasas de fallo, duration outliers) +4. Si tiene assertions, verificar que se evaluan y reportar resultados recientes + +### Al detectar problemas: + +**Problemas criticos (corregir inmediatamente):** +- Tabla faltante → aplicar migraciones con `fn ops init` +- app.md faltante → notificar que la app no puede indexarse +- operations.db en la raiz → eliminar (es un error de ubicacion) + +**Problemas de integridad (reportar con detalle):** +- References rotas (entity_id, type_ref, pipeline_id que no existen) +- Relations huerfanas +- Assertions sobre entities inexistentes + +**Problemas de completitud (sugerir accion):** +- Entities sin metadata → sugerir poblar con datos reales +- Executions sin duration_ms → sugerir capturar metricas +- Failures sin error message → sugerir registrar errores +- Entities sin snapshot → sugerir `fn ops snapshot update` +- Assertions activas nunca evaluadas → sugerir `fn ops assertion eval` + +**Datos vacios (informar, no necesariamente un error):** +- Apps sin entities/relations → la app puede ser nueva o no usar operations +- Apps sin executions → nunca se ha ejecutado via el ciclo reactivo +- Apps sin logs → puede no tener la migracion 003 aplicada + +--- + +## Reparaciones disponibles + +El recopilador puede sugerir o ejecutar estas reparaciones: + +```bash +cd /home/lucas/fn_registry + +# Aplicar migraciones faltantes +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name} + +# Actualizar snapshot desactualizado +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID" + +# Verificar snapshots +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db + +# Evaluar assertions pendientes +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID" + +# Re-indexar para que la app aparezca en registry.db +./fn index + +# Ver grafo de la app (util para diagnostico visual) +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db +``` + +--- + +## Deteccion de anomalias en datos + +Ademas de la integridad referencial, busca patrones anomalos: + +```bash +APP_DB="apps/{app_name}/operations.db" + +# Executions con duration excesiva (>5 min) +sqlite3 "$APP_DB" "SELECT id, pipeline_id, duration_ms FROM executions WHERE duration_ms > 300000;" + +# Tasa de fallo por pipeline (>50% es alarmante) +sqlite3 "$APP_DB" " + SELECT pipeline_id, + COUNT(*) as total, + ROUND(100.0 * SUM(CASE WHEN status='failure' THEN 1 ELSE 0 END) / COUNT(*), 1) as fail_pct + FROM executions + GROUP BY pipeline_id + HAVING fail_pct > 50;" + +# Entities que llevan mucho tiempo en stale (>7 dias) +sqlite3 "$APP_DB" "SELECT id, name, updated_at FROM entities WHERE status = 'stale' AND updated_at < datetime('now', '-7 days');" + +# Assertions con tasa de fallo alta +sqlite3 "$APP_DB" " + SELECT a.name, a.severity, + COUNT(*) as total, + SUM(CASE WHEN ar.status='fail' THEN 1 ELSE 0 END) as fails + FROM assertions a + JOIN assertion_results ar ON a.id = ar.assertion_id + GROUP BY a.id + HAVING fails > total/2;" + +# Relations en status 'designed' que ya tienen executions (deberian ser 'running' o 'implemented') +sqlite3 "$APP_DB" " + SELECT r.id, r.name, r.status, COUNT(e.id) as exec_count + FROM relations r + JOIN executions e ON e.relation_id = r.id + WHERE r.status = 'designed' + GROUP BY r.id;" +``` + +--- + +## Formato de reporte + +Al reportar al usuario, usar este formato consistente: + +``` +=== APP: {nombre} === + +Estructura: + [OK] app.md | [OK] operations.db | [OK] .gitignore + +Schema: + [OK] Todas las tablas presentes (o listar faltantes) + +Datos: + Entities: N (M active, X stale, Y corrupted) + Relations: N (status breakdown) + Executions: N (X success, Y failure) — avg duration: Z ms + Assertions: N (X active, Y evaluadas) + Logs: N (X errors, Y warns) + Snapshots: N (X al dia, Y desactualizados) + +Problemas encontrados: + [FAIL] {descripcion del problema critico} + [WARN] {descripcion del warning} + +Acciones sugeridas: + 1. {accion para resolver problema} + 2. {accion para resolver warning} +``` + +--- + +## Errores comunes a detectar + +1. **operations.db sin migracion 003** → falta tabla `logs` (docker_tui y pipeline_launcher actualmente) +2. **Entities con type_ref que no existe en registry.db** → el tipo fue renombrado o eliminado +3. **Relations con via que no existe** → la funcion fue renombrada o eliminada +4. **Executions sin relation_id** → el ejecutor no vinculo la ejecucion a una relation +5. **Assertions activas nunca evaluadas** → el ciclo reactivo no esta completo +6. **Snapshots desactualizados** → el tipo cambio de version en registry.db +7. **App no indexada en registry.db** → falta `fn index` o falta app.md +8. **Status de entity no refleja la realidad** → stale cuando deberia ser active, o active cuando fallo +9. **Logs con referencias huerfanas** → entity_id o execution_id que ya no existen +10. **Relations en 'designed' con executions** → el status no se actualizo al ejecutar diff --git a/.claude/commands/analysis.md b/.claude/commands/analysis.md new file mode 100644 index 00000000..689521fe --- /dev/null +++ b/.claude/commands/analysis.md @@ -0,0 +1,371 @@ +# /analysis — Trabajar con analisis Jupyter y notebooks del registry + +Eres un agente de analisis de datos. Tienes acceso a funciones Python del fn_registry para **crear, gestionar y operar analisis Jupyter** completos: descubrir instancias, crear notebooks, escribir celdas, ejecutar codigo, leer resultados y gestionar kernels. Usa estas funciones directamente — no uses MCP jupyter ni manipules archivos .ipynb a mano. + +--- + +## Como ejecutar funciones + +```bash +PYTHON="python/.venv/bin/python3" + +# Ejecutar codigo inline +$PYTHON -c " +import sys; sys.path.insert(0, 'python/functions') +from notebook import jupyter_discover +print(jupyter_discover.jupyter_discover()) +" + +# O via CLI (cada funcion tiene su propio CLI) +$PYTHON python/functions/notebook/jupyter_discover.py --json +$PYTHON python/functions/notebook/jupyter_write.py create notebooks/01.ipynb +$PYTHON python/functions/notebook/jupyter_exec.py append notebooks/01.ipynb "print('hola')" +$PYTHON python/functions/notebook/jupyter_kernel.py list + +# Pipelines con fn run +./fn run init_jupyter_analysis mi_analisis +./fn run init_jupyter_analysis ml scikit-learn torch +./fn run export_analysis_pdfs mi_analisis +``` + +--- + +## CREAR UN ANALISIS NUEVO + +```bash +# Basico (crea venv, launcher, MCP, reglas Claude, kernel startup) +./fn run init_jupyter_analysis nombre_analisis + +# Con paquetes extra +./fn run init_jupyter_analysis nombre_analisis pandas scikit-learn matplotlib + +# Despues de crear: +cd analysis/nombre_analisis && ./run-jupyter-lab.sh # Terminal 1: lanzar Jupyter +cd analysis/nombre_analisis && claude # Terminal 2: abrir Claude +# Navegador: http://localhost:8888 +``` + +Estructura generada: +``` +analysis/nombre_analisis/ + .venv/ # Deps propias (gitignored) + .mcp.json # MCP jupyter (gitignored) + .claude/CLAUDE.md # Reglas para agentes + .ipython/profile_default/startup/ + 00_fn_registry.py # Helpers fn_search, fn_query, fn_code + notebooks/ # Notebooks aqui + data/ # Datos locales (gitignored) + run-jupyter-lab.sh # Launcher colaborativo + pyproject.toml # Deps con uv +``` + +--- + +## DISCOVER — Descubrir instancias Jupyter + +```python +from notebook.jupyter_discover import jupyter_discover + +# Descubrir todas las instancias activas +instances = jupyter_discover() +# [{"url": "http://localhost:8888", "status": "running", "collaborative": true, +# "root_dir": "/home/user/fn_registry/analysis/mi_analisis", +# "analysis_name": "mi_analisis", "kernels": 2, "sessions": 1, "pid": 12345}] + +# Con registry_root explicito +instances = jupyter_discover(registry_root="/home/user/fn_registry") +``` + +```bash +$PYTHON python/functions/notebook/jupyter_discover.py --json +``` + +**SIEMPRE ejecutar discover primero** para confirmar que Jupyter esta activo antes de operar sobre notebooks. + +--- + +## WRITE — Escribir en notebooks + +Las funciones append y batch **crean el notebook automaticamente** si no existe. No es necesario abrir el notebook en el navegador primero. + +```python +from notebook.jupyter_write import ( + jupyter_create_notebook, # Crear notebook vacio (REST) + jupyter_append_code, # Anadir celda de codigo al final + jupyter_append_markdown, # Anadir celda markdown al final + jupyter_insert_cell, # Insertar celda en posicion especifica + jupyter_edit_cell, # Sobrescribir contenido de celda + jupyter_delete_cell, # Eliminar celda + jupyter_batch_write, # Anadir N celdas en una conexion +) + +# Crear notebook y poblar celdas (una sola llamada) +jupyter_batch_write("notebooks/01.ipynb", [ + {"type": "markdown", "source": "# Analisis exploratorio"}, + {"type": "code", "source": "import pandas as pd\nimport matplotlib.pyplot as plt"}, + {"type": "code", "source": "df = pd.read_csv('data/dataset.csv')\ndf.head()"}, +]) +# {"action": "batch", "cells_added": 3, "notebook": "notebooks/01.ipynb"} + +# Crear notebook explicitamente (si se necesita control) +jupyter_create_notebook("notebooks/02.ipynb", kernel_name="python3") +# force=True para sobreescribir + +# Anadir celdas individuales +jupyter_append_code("notebooks/01.ipynb", "df.describe()") +jupyter_append_markdown("notebooks/01.ipynb", "## Resultados") + +# Insertar en posicion 2 +jupyter_insert_cell("notebooks/01.ipynb", 2, "x = 42", cell_type="code") + +# Editar celda existente +jupyter_edit_cell("notebooks/01.ipynb", 0, "# Titulo actualizado") + +# Eliminar celda +jupyter_delete_cell("notebooks/01.ipynb", 3) +``` + +```bash +# CLI +$PYTHON python/functions/notebook/jupyter_write.py create notebooks/01.ipynb +$PYTHON python/functions/notebook/jupyter_write.py append-code notebooks/01.ipynb "print('hola')" +$PYTHON python/functions/notebook/jupyter_write.py append-markdown notebooks/01.ipynb "## Titulo" +$PYTHON python/functions/notebook/jupyter_write.py insert notebooks/01.ipynb 2 "x = 42" --type code +$PYTHON python/functions/notebook/jupyter_write.py edit notebooks/01.ipynb 0 "# Nuevo titulo" +$PYTHON python/functions/notebook/jupyter_write.py delete notebooks/01.ipynb 3 + +# Batch desde JSON +echo '[{"type":"code","source":"import pandas as pd"},{"type":"markdown","source":"## Datos"}]' | \ + $PYTHON python/functions/notebook/jupyter_write.py batch notebooks/01.ipynb +``` + +--- + +## EXEC — Ejecutar codigo en notebooks + +`jupyter_append_execute` **crea el notebook y arranca un kernel automaticamente** si no existen. No es necesario abrir el notebook manualmente. + +```python +from notebook.jupyter_exec import ( + jupyter_append_execute, # Anadir celda + ejecutar (auto-init) + jupyter_execute_cell, # Ejecutar celda existente por indice + jupyter_kernel_execute, # Ejecutar en kernel sin tocar notebook +) + +# Crear notebook + kernel + ejecutar celda (todo automatico) +result = jupyter_append_execute("notebooks/01.ipynb", "import pandas as pd\nprint(pd.__version__)") +# {"cell_index": 0, "outputs": ["2.2.1"]} + +# Ejecutar mas celdas +result = jupyter_append_execute("notebooks/01.ipynb", "df = pd.DataFrame({'a': [1,2,3]})\ndf.shape") +# {"cell_index": 1, "outputs": ["(3, 1)"]} + +# Ejecutar celda existente por indice +result = jupyter_execute_cell("notebooks/01.ipynb", 0) +# {"cell_index": 0, "outputs": ["2.2.1"]} + +# Ejecutar en kernel directamente (sin tocar notebook) +result = jupyter_kernel_execute("len(df)") +# {"outputs": ["3"], "status": "ok"} +``` + +```bash +# CLI +$PYTHON python/functions/notebook/jupyter_exec.py append notebooks/01.ipynb "print('hola')" +$PYTHON python/functions/notebook/jupyter_exec.py cell notebooks/01.ipynb 3 +$PYTHON python/functions/notebook/jupyter_exec.py kernel "print(42)" +``` + +--- + +## READ — Leer notebooks + +Lee el estado en memoria (CRDT), incluyendo cambios no guardados. + +```python +from notebook.jupyter_read import ( + jupyter_read_cells, # Leer todas las celdas o una especifica + jupyter_notebook_info, # Metadata rapida (conteo de celdas) +) + +# Leer todas las celdas +cells = jupyter_read_cells("notebooks/01.ipynb") +# [{"index": 0, "type": "code", "source": "import pandas", "outputs": ["..."]}] + +# Leer celda especifica +cell = jupyter_read_cells("notebooks/01.ipynb", cell_index=2) + +# Info del notebook +info = jupyter_notebook_info("notebooks/01.ipynb") +# {"total_cells": 10, "code_cells": 7, "markdown_cells": 3} +``` + +```bash +$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --json +$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --cell 2 --json +$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --info --json +``` + +--- + +## KERNEL — Gestionar kernels + +```python +from notebook.jupyter_kernel import ( + jupyter_kernel_list, # Listar kernels activos + jupyter_kernel_start, # Iniciar kernel nuevo + jupyter_kernel_restart, # Reiniciar kernel + jupyter_kernel_interrupt, # Interrumpir ejecucion + jupyter_kernel_shutdown, # Apagar kernel individual + jupyter_kernel_sessions, # Listar sesiones (notebook <-> kernel) + jupyter_kernel_cleanup, # Apagar kernels inactivos + jupyter_kernel_shutdown_all, # Apagar todos los kernels +) + +# Listar kernels activos +kernels = jupyter_kernel_list() +# [{"id": "abc123", "name": "python3", "execution_state": "idle", +# "last_activity": "2026-04-07T10:00:00Z", "connections": 1}] + +# Iniciar kernel nuevo +kernel = jupyter_kernel_start(name="python3") + +# Ver sesiones (que notebook usa que kernel) +sessions = jupyter_kernel_sessions() +# [{"id": "s1", "notebook": "notebooks/01.ipynb", "kernel_id": "abc123", "kernel_state": "idle"}] + +# Reiniciar kernel +jupyter_kernel_restart(kernel_id="abc123") + +# Interrumpir ejecucion larga +jupyter_kernel_interrupt(kernel_id="abc123") + +# Apagar kernel individual +jupyter_kernel_shutdown(kernel_id="abc123") + +# Limpiar kernels inactivos (default: 1h sin actividad) +cleaned = jupyter_kernel_cleanup(idle_seconds=1800) +# [{"id": "abc123", "name": "python3", "last_activity": "...", "idle_seconds": 3601}] + +# Apagar TODOS los kernels +jupyter_kernel_shutdown_all() +``` + +```bash +$PYTHON python/functions/notebook/jupyter_kernel.py list +$PYTHON python/functions/notebook/jupyter_kernel.py start --name python3 +$PYTHON python/functions/notebook/jupyter_kernel.py sessions +$PYTHON python/functions/notebook/jupyter_kernel.py restart +$PYTHON python/functions/notebook/jupyter_kernel.py interrupt +$PYTHON python/functions/notebook/jupyter_kernel.py shutdown +$PYTHON python/functions/notebook/jupyter_kernel.py cleanup --idle-seconds 1800 +$PYTHON python/functions/notebook/jupyter_kernel.py shutdown-all +``` + +--- + +## Flujos tipicos + +### 1. Analisis desde cero (sin abrir navegador) + +```python +import sys; sys.path.insert(0, "python/functions") +from notebook.jupyter_discover import jupyter_discover +from notebook.jupyter_exec import jupyter_append_execute + +# 1. Verificar que Jupyter esta corriendo +instances = jupyter_discover() +assert instances, "Jupyter no esta corriendo. Ejecuta: cd analysis/mi_analisis && ./run-jupyter-lab.sh" + +# 2. Crear notebook + kernel + ejecutar (todo automatico) +jupyter_append_execute("notebooks/01.ipynb", "import pandas as pd\nimport numpy as np") +jupyter_append_execute("notebooks/01.ipynb", "df = pd.read_csv('data/dataset.csv')\ndf.shape") +jupyter_append_execute("notebooks/01.ipynb", "df.describe()") +``` + +### 2. Poblar notebook con estructura y ejecutar + +```python +from notebook.jupyter_write import jupyter_batch_write +from notebook.jupyter_exec import jupyter_append_execute + +# 1. Crear estructura del notebook +jupyter_batch_write("notebooks/02.ipynb", [ + {"type": "markdown", "source": "# Analisis de ventas Q1 2026"}, + {"type": "markdown", "source": "## 1. Carga de datos"}, + {"type": "code", "source": "import pandas as pd\ndf = pd.read_csv('data/ventas.csv')"}, + {"type": "markdown", "source": "## 2. Exploracion"}, + {"type": "code", "source": "df.info()"}, + {"type": "code", "source": "df.describe()"}, + {"type": "markdown", "source": "## 3. Visualizacion"}, +]) + +# 2. Ejecutar celdas de codigo +from notebook.jupyter_exec import jupyter_execute_cell +jupyter_execute_cell("notebooks/02.ipynb", 2) # import + read_csv +jupyter_execute_cell("notebooks/02.ipynb", 4) # info +jupyter_execute_cell("notebooks/02.ipynb", 5) # describe +``` + +### 3. Limpiar recursos + +```python +from notebook.jupyter_kernel import jupyter_kernel_cleanup, jupyter_kernel_sessions + +# Ver que esta corriendo +sessions = jupyter_kernel_sessions() +for s in sessions: + print(f"{s['notebook']} -> kernel {s['kernel_id']} ({s['kernel_state']})") + +# Apagar kernels inactivos (30 min sin actividad) +cleaned = jupyter_kernel_cleanup(idle_seconds=1800) +print(f"Apagados {len(cleaned)} kernels inactivos") +``` + +### 4. Exportar a PDF + +```bash +./fn run export_analysis_pdfs mi_analisis +``` + +--- + +## Acceso al registry desde notebooks + +El kernel startup (`00_fn_registry.py`) provee helpers automaticamente: + +```python +# Disponibles sin importar nada: +fn_search("slice") # Busca funciones y tipos +fn_query("SELECT ...") # SQL directo sobre registry.db +fn_code("filter_list_py_core") # Codigo fuente de una funcion + +# Importar funciones Python del registry: +from core import filter_list, map_list, reduce_list +from finance import sma, ema, rsi +``` + +--- + +## Pipelines disponibles + +| Pipeline | Descripcion | +|----------|-------------| +| `init_jupyter_analysis` | Crea analisis completo (venv, launcher, MCP, reglas) | +| `export_analysis_pdfs` | Exporta notebooks de un analisis a PDF | +| `write_jupyter_launcher` | Genera script run-jupyter-lab.sh | +| `write_jupyter_registry_kernel` | Genera kernel startup con helpers del registry | +| `write_claude_jupyter_rules` | Genera .claude/CLAUDE.md con reglas para agentes | +| `write_mcp_jupyter_config` | Genera .mcp.json con config de jupyter-mcp-server | + +--- + +## Buscar mas funciones + +```bash +./fn search "jupyter" +./fn search "notebook" +sqlite3 registry.db "SELECT id, description FROM functions WHERE domain = 'notebook' ORDER BY name;" +``` + +$ARGUMENTS diff --git a/.claude/commands/app.md b/.claude/commands/app.md new file mode 100644 index 00000000..7a764c6e --- /dev/null +++ b/.claude/commands/app.md @@ -0,0 +1,331 @@ +# /app — Crear, configurar y desplegar apps del registry + +Eres un agente orquestador de apps para fn_registry. Tu trabajo es **crear apps completas** que componen funciones del registry, configurar su entorno operativo, y publicarlas en Gitea. Usas los agentes especializados del ciclo reactivo para cada fase. + +--- + +## Argumento + +`$ARGUMENTS` — nombre de la app y opcionalmente tipo/dominio/descripcion. Ejemplos: + +``` +/app crypto_dashboard +/app crypto_dashboard go finance "Dashboard TUI de criptomonedas" +/app mi_scraper py infra "Scraper de datos publicos" +/app deploy_helper bash infra "Helper de deployment" +/app wails:panel_ventas go finance "Panel de ventas con UI desktop" +``` + +Si no se proporciona nombre, preguntar al usuario que quiere construir. + +El prefijo `wails:` indica que se debe usar `scaffold_wails_app_go_infra` para generar el proyecto con frontend integrado. + +--- + +## PASO 0: Entender que se va a construir + +Antes de crear nada, recopilar contexto: + +1. **Parsear argumentos**: nombre, lang (go|py|bash|ts), domain, descripcion +2. **Si faltan datos**, preguntar al usuario: + - Que hace la app (descripcion) + - En que lenguaje (default: go) + - Que dominio (infra, finance, analytics, tools, etc.) + - Si necesita UI (TUI con Bubbletea, desktop con Wails, o sin UI) +3. **Consultar registry.db** para encontrar funciones reutilizables: + +```bash +# Buscar funciones relevantes por descripcion +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;" + +# Buscar apps similares +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;" + +# Verificar que el nombre no esta tomado +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';" +``` + +4. **Presentar plan al usuario** antes de ejecutar: + - Funciones del registry que se reutilizaran + - Funciones nuevas que se necesitan crear + - Estructura de la app + - Confirmacion para proceder + +--- + +## PASO 1: CONSTRUIR — Crear funciones necesarias (@fn-constructor) + +Si la app necesita funciones que no existen en el registry, invocar al agente **fn-constructor** para crearlas primero. + +**Cuando invocar fn-constructor:** +- La app necesita logica pura que seria reutilizable (ej: un parser, un transformer, un validator) +- La app necesita un pipeline que compone funciones existentes +- La app necesita tipos nuevos para modelar su dominio + +**Como invocar:** + +Usar el Agent tool con `subagent_type: "fn-constructor"` pasando: +- Que funciones/tipos crear +- Que dominio y lenguaje +- Que funciones existentes reutilizar (IDs del registry) +- Contexto de para que se van a usar (la app que estamos creando) + +**NO invocar fn-constructor para:** +- Logica especifica de la app que no es reutilizable (eso va directamente en la app) +- Codigo que depende de config/credenciales hardcodeadas + +Despues de que fn-constructor termine, verificar que todo se indexo: + +```bash +cd /home/lucas/fn_registry && ./fn index +# Verificar cada funcion creada +./fn show {id_de_cada_funcion} +``` + +--- + +## PASO 2: Crear la app + +### Estructura base (todos los lenguajes) + +```bash +mkdir -p /home/lucas/fn_registry/apps/{app_name} +``` + +### app.md (OBLIGATORIO — siempre primero) + +```yaml +--- +name: {app_name} +lang: {go|py|bash|ts} +domain: {domain} +description: "{descripcion}" +tags: [{tags}] +uses_functions: + - {id_funcion_1} + - {id_funcion_2} +uses_types: [] +framework: "{bubbletea|wails|httpx|...}" +entry_point: "{main.go|main.py|main.sh}" +dir_path: "apps/{app_name}" +repo_url: "" +--- + +## Arquitectura + +{Descripcion de como funciona la app, que funciones compone, flujo de datos} + +## Notas + +{Notas adicionales, dependencias externas, configuracion necesaria} +``` + +### .gitignore (OBLIGATORIO) + +``` +operations.db +operations.db-wal +operations.db-shm +__pycache__/ +build/ +*.exe +*.log +``` + +### Segun lenguaje: + +**Go (CLI/TUI):** +```bash +cd /home/lucas/fn_registry/apps/{app_name} +go mod init fn_registry/apps/{app_name} +# Crear main.go, app/, config/, views/ segun necesidad +``` + +**Go (Wails — desktop con UI):** +```bash +# Usar scaffold del registry +cd /home/lucas/fn_registry +./fn run scaffold_wails_app -- --name {app_name} --dir apps/{app_name} +``` + +**Python:** +```bash +# Crear main.py con sys.path al registry +# Import pattern: sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions")) +``` + +**Bash:** +```bash +# Crear main.sh con source a funciones del registry +# Pattern: source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh" +chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh +``` + +### Inicializar operations.db + +```bash +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name} +``` + +### Indexar en registry.db + +```bash +cd /home/lucas/fn_registry && ./fn index +# Verificar +sqlite3 registry.db "SELECT id, name, lang, domain FROM apps WHERE name = '{app_name}';" +``` + +--- + +## PASO 3: EJECUTAR — Verificar que funciona (@fn-executor) + +Invocar al agente **fn-executor** para: + +1. Verificar que la app compila/ejecuta correctamente +2. Configurar entities y relations en operations.db si la app maneja datos +3. Ejecutar una primera ejecucion de prueba +4. Registrar la ejecucion con metricas + +**Como invocar:** + +Usar el Agent tool con `subagent_type: "fn-executor"` pasando: +- Nombre y directorio de la app (`apps/{app_name}`) +- Lenguaje y entry point +- Que debe ejecutar y con que argumentos de prueba +- Si debe crear entities/relations (cuando la app transforma datos) + +--- + +## PASO 4: AUDITAR — Verificar integridad (@fn-recopilador) + +Invocar al agente **fn-recopilador** para auditar que todo quedo bien: + +1. Estructura de la app (app.md, operations.db, .gitignore) +2. Schema de operations.db completo +3. Integridad de datos (entities, relations, executions) +4. Coherencia con registry.db (uses_functions, type_refs) +5. App indexada correctamente + +**Como invocar:** + +Usar el Agent tool con `subagent_type: "fn-recopilador"` pasando: +- Nombre de la app a auditar +- Que es una app nueva y debe verificar todo desde cero + +Si el recopilador detecta problemas, corregirlos antes de continuar. + +--- + +## PASO 5: PUBLICAR en Gitea (@gitea) + +Una vez la app esta funcionando y auditada, publicarla como repo independiente en Gitea. + +**Como invocar:** + +Usar el Agent tool con `subagent_type: "gitea"` pasando: +- Crear repo `{app_name}` en la organizacion `dataforge` de Gitea +- La URL base de Gitea: `https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com` +- Inicializar el repo con el contenido de `apps/{app_name}/` +- El repo debe tener su propio `.git` independiente del fn_registry + +**Pasos que el agente gitea debe ejecutar:** + +```bash +# 1. Crear repo en Gitea (via API) +# 2. Inicializar git en la app +cd /home/lucas/fn_registry/apps/{app_name} +git init +git add -A +git commit -m "Initial commit: {app_name} — {descripcion}" + +# 3. Configurar remote y push +git remote add origin https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/{app_name}.git +git push -u origin master + +# 4. Actualizar repo_url en app.md +``` + +**Despues de publicar**, actualizar el `repo_url` en app.md y re-indexar: + +```bash +cd /home/lucas/fn_registry && ./fn index +``` + +--- + +## PASO 6: Resumen final + +Reportar al usuario: + +``` +=== APP CREADA: {app_name} === + +Directorio: apps/{app_name}/ +Lenguaje: {lang} +Dominio: {domain} +Framework: {framework} +Entry point: {entry_point} + +Funciones del registry usadas: + - {id1}: {descripcion} + - {id2}: {descripcion} + +Funciones nuevas creadas: + - {id3}: {descripcion} + +Operations: + Entities: N + Relations: N + Executions: N (primera ejecucion: {status}) + +Repo Gitea: {repo_url} + +Para ejecutar: + cd apps/{app_name} && {comando_ejecucion} +``` + +--- + +## Flujos segun tipo de app + +### App Go TUI (Bubbletea) + +1. Consultar funciones TUI existentes: `sqlite3 registry.db "SELECT id, description FROM functions WHERE domain = 'tui' ORDER BY name;"` +2. Crear app con framework bubbletea +3. Estructura: main.go + app/model.go + views/ + config/ +4. Tag `launcher` en app.md si debe aparecer en Pipeline Launcher + +### App Go Desktop (Wails) + +1. Usar `scaffold_wails_app_go_infra` para generar el proyecto +2. Consultar componentes Wails del registry: `sqlite3 registry.db "SELECT id, description FROM functions WHERE id LIKE '%wails%' ORDER BY name;"` +3. Frontend usa @fn_library (Mantine v9, @tabler/icons-react) +4. Bindings Go via `wails_bind_crud_go_infra` + +### App Python + +1. Consultar funciones Python: `sqlite3 registry.db "SELECT id, description FROM functions WHERE lang = 'py' AND domain = 'DOMINIO' ORDER BY name;"` +2. Import pattern con sys.path al registry +3. Deps con requirements.txt o pyproject.toml + +### App Bash + +1. Consultar funciones Bash: `sqlite3 registry.db "SELECT id, description FROM functions WHERE lang = 'bash' ORDER BY name;"` +2. Source pattern con REGISTRY_ROOT +3. set -euo pipefail obligatorio + +--- + +## Reglas + +- **Codigo reutilizable** va en `functions/`, NO en la app → usar fn-constructor +- **Codigo especifico** de la app va en `apps/{app_name}/` +- **operations.db** SOLO dentro de la app, NUNCA en la raiz +- **registry.db** SOLO en la raiz, NUNCA en apps +- Toda app DEBE tener `app.md` con frontmatter completo +- `uses_functions` en app.md DEBE listar TODAS las funciones del registry importadas +- Siempre `./fn index` despues de crear/modificar la app +- Siempre auditar con fn-recopilador antes de publicar + +$ARGUMENTS diff --git a/.claude/commands/create_functions.md b/.claude/commands/create_functions.md new file mode 100644 index 00000000..6428e117 --- /dev/null +++ b/.claude/commands/create_functions.md @@ -0,0 +1,270 @@ +# /create_functions — Crear funciones para el registry a partir de una peticion + +Eres un agente orquestador que evalua una peticion del usuario, consulta el registry, planifica las funciones necesarias y las crea en paralelo usando agentes fn-constructor especializados. Tambien creas unit tests y verificas que todo quedo indexado correctamente. + +--- + +## Argumento + +`$ARGUMENTS` — descripcion de lo que el usuario necesita. Ejemplos: + +``` +/create_functions funciones para parsear y validar JSON schema en Go +/create_functions pipeline Python para ETL de CSVs con filtrado y agregacion +/create_functions funciones de hashing y encoding para ciberseguridad en Go +/create_functions componentes React para formularios con validacion +/create_functions funciones Bash para gestion de contenedores Docker +``` + +Si `$ARGUMENTS` esta vacio, preguntar al usuario que funciones necesita. + +--- + +## FASE 1: EVALUAR — Entender la peticion + +1. **Parsear la peticion** para identificar: + - Dominio(s) involucrados (core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, notebook, ui) + - Lenguaje(s) preferido(s) (go, py, bash, typescript). Si no se especifica, inferir del contexto. + - Tipo de funciones necesarias: puras (algoritmos, transformaciones), impuras (I/O, red, DB), pipelines (composiciones), tipos, componentes + - Nivel de granularidad: funciones atomicas vs composiciones + +2. **Si la peticion es ambigua**, preguntar al usuario SOLO lo esencial (no mas de 2 preguntas). + +--- + +## FASE 2: OBSERVAR — Consultar el registry + +Consultar `registry.db` para encontrar funciones existentes relevantes y evitar duplicados. + +```bash +# Buscar funciones similares por nombre y descripcion (OBLIGATORIO — usar multiples terminos) +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;" + +# Buscar tipos relacionados +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;" + +# Funciones del dominio objetivo +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;" + +# Tipos del dominio objetivo +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;" + +# Funciones que podrian componerse (misma firma de retorno) +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;" +``` + +**Clasificar resultados en:** +- **Reutilizables directamente**: funciones que ya hacen lo que se necesita +- **Componibles**: funciones que pueden usarse como building blocks +- **Similares pero diferentes**: funciones parecidas que confirman que no hay duplicado exacto + +--- + +## FASE 3: PLANIFICAR — Disenar las funciones con un agente Plan + +Invocar el Agent tool con `subagent_type: "Plan"` para disenar la lista de funciones a crear. + +El prompt al agente Plan debe incluir: +- La peticion original del usuario +- Las funciones existentes encontradas en FASE 2 (IDs y descripciones) +- Los tipos existentes relevantes +- Las reglas de pureza del registry + +El agente Plan debe producir una lista estructurada de funciones a crear, cada una con: +- **nombre** (snake_case) +- **kind** (function | pipeline | component) +- **lang** (go | py | bash | typescript) +- **domain** +- **purity** (pure | impure) — justificando por que +- **signature** propuesta +- **description** breve +- **uses_functions** — IDs de funciones existentes que reutiliza +- **uses_types** — IDs de tipos existentes que usa +- **dependencias** — si una funcion nueva depende de otra funcion nueva del mismo batch, indicar el orden +- **tests** — que se debe testear (casos de exito, edge cases, errores) + +**Reglas del plan:** +- Funciones puras primero, impuras despues, pipelines al final +- Maximizar reutilizacion de funciones existentes +- Cada funcion debe tener tests propuestos +- El plan debe indicar el **orden de creacion** (las que tienen dependencias internas van despues) +- Agrupar funciones independientes para creacion en paralelo + +**NO pedir confirmacion al usuario** — proceder directamente a la fase de construccion. Mostrar el plan brevemente en el output como referencia pero sin pausar: + +--- + +## FASE 4: CONSTRUIR — Crear funciones en paralelo con fn-constructor + +Para cada batch del plan, lanzar agentes `fn-constructor` **en paralelo** (un agente por funcion o grupo pequeno de funciones relacionadas). + +**Como invocar cada fn-constructor:** + +Usar el Agent tool con `subagent_type: "fn-constructor"` pasando un prompt completo con: + +``` +Crea la siguiente funcion para el registry fn_registry en /home/lucas/fn_registry: + +Funcion: {nombre} +Kind: {kind} +Lang: {lang} +Domain: {domain} +Purity: {purity} +Signature: {signature} +Description: {descripcion} +Uses_functions: [{ids}] +Uses_types: [{ids}] + +Tests requeridos: +- {test1}: {descripcion del test} +- {test2}: {descripcion del test} +- {test3}: {descripcion del test} + +Contexto: Esta funcion es parte de un batch para {descripcion general del objetivo}. +Funciones existentes del registry que puedes reutilizar: {ids relevantes} + +IMPORTANTE: +- Crear el archivo de codigo Y el .md con frontmatter completo +- Crear el archivo de tests correspondiente +- Marcar tested: true en el .md si creas tests +- Respetar las reglas de pureza +- Usar tipos nativos en la firma +- file_path relativo a la raiz del registry +- NO ejecutar fn index (lo hare yo al final) +``` + +**Orden de ejecucion:** +1. Lanzar todos los fn-constructor del Batch 1 en paralelo +2. Esperar a que terminen +3. Lanzar todos los fn-constructor del Batch 2 en paralelo (dependen de Batch 1) +4. Repetir para cada batch subsiguiente + +**Sin limite de agentes en paralelo** — lanzar todos los fn-constructor del batch simultaneamente para maxima velocidad. + +--- + +## FASE 5: INDEXAR — Registrar todo en el registry + +Despues de que TODOS los fn-constructor terminen: + +```bash +# Indexar todo de una vez +cd /home/lucas/fn_registry && ./fn index +``` + +Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes: +- ID duplicado → renombrar +- uses_functions referencia ID inexistente → verificar que el batch anterior se creo correctamente +- Violacion de pureza → ajustar purity o quitar dependencia impura +- file_path incorrecto → corregir la ruta + +--- + +## FASE 6: VERIFICAR — Asegurar que todo esta correcto + +### 6.1 Verificar indexacion + +```bash +# Verificar cada funcion creada +cd /home/lucas/fn_registry +./fn show {id_de_cada_funcion} + +# Verificar que no hay funciones sin params_schema +./fn check params +``` + +### 6.2 Ejecutar tests + +Para cada funcion con tests, ejecutar: + +```bash +cd /home/lucas/fn_registry + +# Go +CGO_ENABLED=1 go test -tags fts5 -v -run TestNombreDelTest ./functions/{domain}/ + +# Python +python/.venv/bin/python3 -m pytest python/functions/{domain}/{nombre}_test.py -v + +# TypeScript +cd frontend && pnpm exec vitest run functions/{domain}/{nombre}.test.ts + +# Bash (si hay tests) +bash bash/functions/{domain}/{nombre}_test.sh +``` + +### 6.3 Verificar integridad + +```bash +# Verificar que todas las funciones nuevas estan en la BD +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;" + +# Verificar que los tests estan indexados +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;" + +# Verificar dependencias +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';" +``` + +### 6.4 Si algo fallo + +- Si un test falla → corregir el codigo y re-ejecutar +- Si una funcion no se indexo → verificar el .md y re-indexar +- Si hay errores de integridad → corregir y re-indexar +- NO continuar al reporte si hay tests fallando o funciones sin indexar + +--- + +## FASE 7: REPORTE — Resumen final + +``` +=== FUNCIONES CREADAS === + +Peticion: {descripcion original} + +Funciones del registry reutilizadas: + - {id}: {descripcion} + +Funciones nuevas: + - {id} [{kind}, {purity}, {lang}] — {descripcion} + Tests: N pasando + Archivo: {file_path} + + - {id} [{kind}, {purity}, {lang}] — {descripcion} + Tests: N pasando + Archivo: {file_path} + +Tipos nuevos: + - {id}: {descripcion} + +Tests: X/Y pasando +Indexacion: OK + +Para usar estas funciones: + # Go + import "fn_registry/functions/{domain}" + result := domain.FunctionName(args) + + # Python + from {domain} import function_name + + # Bash + source "$FN_REGISTRY_ROOT/bash/functions/{domain}/{name}.sh" +``` + +--- + +## Reglas + +- **SIEMPRE** consultar registry.db antes de crear — evitar duplicados +- **NO pedir confirmacion** — mostrar el plan brevemente y proceder directamente +- **SIEMPRE** crear tests para cada funcion +- **SIEMPRE** indexar y verificar despues de crear +- **Funciones puras primero**, impuras despues, pipelines al final +- **Maximizar paralelismo** en la creacion (agentes fn-constructor en paralelo) +- **Maximizar reutilizacion** de funciones existentes +- **NO crear funciones especificas de una app** — solo codigo reutilizable y generico +- Si el usuario pide algo que ya existe, informar y sugerir reutilizar en vez de duplicar +- Si una funcion del batch falla, las demas del mismo batch pueden continuar independientemente + +$ARGUMENTS diff --git a/cpp/functions/core/dashboard_grid.cpp b/cpp/functions/core/dashboard_grid.cpp new file mode 100644 index 00000000..fe63f4c7 --- /dev/null +++ b/cpp/functions/core/dashboard_grid.cpp @@ -0,0 +1,60 @@ +#include "dashboard_grid.h" +#include +#include + +// Internal state stack to support nested grids. +namespace { + +struct GridState { + int columns; + float spacing; + float col_width; + int counter; // number of dashboard_grid_next() calls so far +}; + +static std::vector g_grid_stack; + +} // namespace + +void dashboard_grid_begin(int columns, float spacing) { + if (columns < 1) columns = 1; + + float available = ImGui::GetContentRegionAvail().x; + float col_width = (available - spacing * static_cast(columns - 1)) + / static_cast(columns); + if (col_width < 1.0f) col_width = 1.0f; + + g_grid_stack.push_back({columns, spacing, col_width, 0}); + + ImGui::BeginGroup(); + ImGui::PushItemWidth(col_width); +} + +void dashboard_grid_next() { + if (g_grid_stack.empty()) return; + + GridState& s = g_grid_stack.back(); + + ImGui::PopItemWidth(); + ImGui::EndGroup(); + + s.counter++; + + if (s.counter % s.columns != 0) { + // Same row: advance horizontally. + ImGui::SameLine(0.0f, s.spacing); + } + // If counter % columns == 0 the next BeginGroup starts a new row automatically. + + ImGui::BeginGroup(); + ImGui::PushItemWidth(s.col_width); +} + +void dashboard_grid_end() { + if (g_grid_stack.empty()) return; + + ImGui::PopItemWidth(); + ImGui::EndGroup(); + + g_grid_stack.pop_back(); +} diff --git a/cpp/functions/core/dashboard_grid.h b/cpp/functions/core/dashboard_grid.h new file mode 100644 index 00000000..fd30ba43 --- /dev/null +++ b/cpp/functions/core/dashboard_grid.h @@ -0,0 +1,17 @@ +#pragma once + +// Dashboard grid — distributes child widgets in N columns. +// Usage: +// dashboard_grid_begin(3); +// // widget 1 (auto placed in col 0) +// dashboard_grid_next(); +// // widget 2 (auto placed in col 1) +// dashboard_grid_next(); +// // widget 3 (auto placed in col 2, wraps to next row) +// dashboard_grid_next(); +// // widget 4 (col 0 of row 2) +// dashboard_grid_end(); + +void dashboard_grid_begin(int columns = 2, float spacing = 8.0f); +void dashboard_grid_next(); // advance to next cell +void dashboard_grid_end(); diff --git a/cpp/functions/core/dashboard_grid.md b/cpp/functions/core/dashboard_grid.md new file mode 100644 index 00000000..bae08919 --- /dev/null +++ b/cpp/functions/core/dashboard_grid.md @@ -0,0 +1,88 @@ +--- +name: dashboard_grid +kind: component +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "void dashboard_grid_begin(int columns = 2, float spacing = 8.0f); void dashboard_grid_next(); void dashboard_grid_end()" +description: "Grid de N columnas para distribuir widgets de dashboard automaticamente con spacing uniforme entre columnas" +tags: [imgui, grid, layout, dashboard, responsive] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/dashboard_grid.cpp" +framework: imgui +params: + - name: columns + desc: "Numero de columnas del grid (minimo 1); el ancho disponible se divide uniformemente entre ellas" + - name: spacing + desc: "Espacio horizontal entre columnas en pixels" +output: "Layout de grid aplicado al contenido entre dashboard_grid_begin/end; cada celda recibe un ancho uniforme calculado a partir del espacio disponible" +--- + +# dashboard_grid + +Divide el ancho disponible en N columnas con spacing uniforme y posiciona cada widget en su celda automaticamente. Soporta grids anidados mediante un stack interno de estado. + +## Uso + +```cpp +dashboard_grid_begin(3, 8.0f); + +// Celda 0 +dashboard_panel_begin("CPU"); +ImGui::Text("%.1f %%", cpu_pct); +dashboard_panel_end(); +dashboard_grid_next(); + +// Celda 1 +dashboard_panel_begin("Memory"); +ImGui::Text("%.0f MB", mem_mb); +dashboard_panel_end(); +dashboard_grid_next(); + +// Celda 2 +dashboard_panel_begin("Disk"); +ImGui::Text("%.0f GB", disk_gb); +dashboard_panel_end(); + +dashboard_grid_end(); +``` + +## Implementacion + +### Calculo de ancho + +``` +col_width = (available_width - spacing * (columns - 1)) / columns +``` + +`available_width` se obtiene de `ImGui::GetContentRegionAvail().x` en el momento de `dashboard_grid_begin`. + +### Mecanica de celdas + +Cada celda es un `BeginGroup`/`EndGroup` con `PushItemWidth(col_width)` para que los widgets internos respeten el ancho de columna. Al llamar `dashboard_grid_next`: + +1. Se cierra la celda actual (`PopItemWidth`, `EndGroup`). +2. Se incrementa el contador interno. +3. Si `counter % columns != 0` se emite `SameLine(0, spacing)` para continuar en la misma fila. +4. Si `counter % columns == 0` no se emite `SameLine`: ImGui pasa a la siguiente fila automaticamente. +5. Se abre la nueva celda (`BeginGroup`, `PushItemWidth`). + +### Grids anidados + +El estado (columnas, spacing, col_width, counter) se guarda en `g_grid_stack` (un `std::vector` de structs en un namespace anonimo). Cada llamada a `dashboard_grid_begin` hace `push_back` y `dashboard_grid_end` hace `pop_back`, permitiendo anidar grids sin conflicto. + +## Notas + +- Llamar `dashboard_grid_next()` entre cada par de widgets, **no** antes del primero ni despues del ultimo. +- El numero de `dashboard_grid_next()` puede ser mayor que `columns - 1`: el grid hace wrap automatico a la siguiente fila. +- Combina bien con `dashboard_panel_begin`/`dashboard_panel_end` para crear dashboards con paneles alineados en cuadricula. +- Si `columns <= 0` se fuerza a 1 para evitar division por cero. diff --git a/cpp/functions/core/dashboard_panel.cpp b/cpp/functions/core/dashboard_panel.cpp new file mode 100644 index 00000000..272c7854 --- /dev/null +++ b/cpp/functions/core/dashboard_panel.cpp @@ -0,0 +1,24 @@ +#include "dashboard_panel.h" +#include + +bool dashboard_panel_begin(const char* title, float min_width, float min_height) { + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 5.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.12f, 0.12f, 0.15f, 1.0f)); + + ImVec2 size(min_width > 0.0f ? min_width : 0.0f, + min_height > 0.0f ? min_height : 0.0f); + + ImGui::BeginChild(title, size, ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + + ImGui::TextUnformatted(title); + ImGui::Separator(); + + return true; +} + +void dashboard_panel_end() { + ImGui::EndChild(); + ImGui::PopStyleColor(1); + ImGui::PopStyleVar(2); +} diff --git a/cpp/functions/core/dashboard_panel.h b/cpp/functions/core/dashboard_panel.h new file mode 100644 index 00000000..fbb7ce68 --- /dev/null +++ b/cpp/functions/core/dashboard_panel.h @@ -0,0 +1,14 @@ +#pragma once + +// Dashboard panel — a styled child window with title bar. +// Usage: +// if (dashboard_panel_begin("Sales")) { +// line_plot("Revenue", xs, ys, N); +// } +// dashboard_panel_end(); // ALWAYS call, even if begin returned false +// +// Features: title bar with text, rounded corners, subtle border, auto-resize. +// min_width/min_height set minimum size constraints. + +bool dashboard_panel_begin(const char* title, float min_width = 200.0f, float min_height = 150.0f); +void dashboard_panel_end(); diff --git a/cpp/functions/core/dashboard_panel.md b/cpp/functions/core/dashboard_panel.md new file mode 100644 index 00000000..1e3d4373 --- /dev/null +++ b/cpp/functions/core/dashboard_panel.md @@ -0,0 +1,57 @@ +--- +name: dashboard_panel +kind: component +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "bool dashboard_panel_begin(const char* title, float min_width = 200.0f, float min_height = 150.0f); void dashboard_panel_end()" +description: "Contenedor estilizado tipo panel para dashboards con titulo, bordes redondeados y tamaño minimo configurable" +tags: [imgui, panel, container, layout, dashboard] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/dashboard_panel.cpp" +framework: imgui +params: + - name: title + desc: "Titulo del panel, tambien sirve como ID de ImGui para distinguir multiples paneles" + - name: min_width + desc: "Ancho minimo del panel en pixels (0 = sin restriccion)" + - name: min_height + desc: "Alto minimo del panel en pixels (0 = sin restriccion)" +output: "true si el panel es visible y se debe renderizar contenido; llamar siempre dashboard_panel_end() independientemente del valor de retorno" +--- + +# dashboard_panel + +Panel estilizado para dashboards ImGui. Envuelve un `BeginChild`/`EndChild` con estilos predefinidos: fondo oscuro (`#1F1F26`), bordes redondeados (5 px), borde visible y separador bajo el titulo. + +## Uso + +```cpp +if (dashboard_panel_begin("Revenue", 300.0f, 200.0f)) { + line_plot("Revenue", xs, ys, N); +} +dashboard_panel_end(); // siempre llamar +``` + +## Implementacion + +- `PushStyleVar` aplica `ChildRounding = 5.0f` y `ChildBorderSize = 1.0f` +- `PushStyleColor` establece el fondo del child a `(0.12, 0.12, 0.15, 1.0)` +- `BeginChild` con `ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY` +- Titulo con `TextUnformatted` seguido de `Separator` +- `dashboard_panel_end` hace `EndChild`, `PopStyleColor(1)`, `PopStyleVar(2)` + +## Notas + +- El titulo actua como ID de ImGui: dos paneles con el mismo titulo en el mismo frame se comportan como uno solo. Usar `##` para diferenciar IDs si se repite el texto: `"Revenue##panel1"`. +- `AutoResizeY` hace que el panel crezca verticalmente con su contenido; `min_height` establece el piso. +- El patron begin/end es idomatico en ImGui: `end` debe llamarse siempre para hacer pop de los estilos, aunque `begin` retorne false. diff --git a/cpp/functions/core/docking_layout.cpp b/cpp/functions/core/docking_layout.cpp new file mode 100644 index 00000000..20a3f0ca --- /dev/null +++ b/cpp/functions/core/docking_layout.cpp @@ -0,0 +1,53 @@ +#include "core/docking_layout.h" +#include "imgui_internal.h" + +ImGuiID docking_layout(DockPreset preset) { + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGuiID dockspace_id = ImGui::DockSpaceOverViewport(0, viewport); + + static bool initialized = false; + if (initialized) { + return dockspace_id; + } + initialized = true; + + if (preset == DockPreset::Default) { + return dockspace_id; + } + + ImGui::DockBuilderRemoveNode(dockspace_id); + ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodeSize(dockspace_id, viewport->Size); + + if (preset == DockPreset::TwoColumns) { + ImGuiID right; + ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.6f, nullptr, &right); + + } else if (preset == DockPreset::ThreeColumns) { + ImGuiID center, right; + ImGuiID left_node; + ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.33f, &left_node, ¢er); + ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.5f, nullptr, &right); + + } else if (preset == DockPreset::SidebarLeft) { + ImGuiID main; + ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.25f, nullptr, &main); + + } else if (preset == DockPreset::SidebarRight) { + ImGuiID sidebar; + ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Right, 0.25f, &sidebar, nullptr); + + } else if (preset == DockPreset::TopBottom) { + ImGuiID bottom; + ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Up, 0.6f, nullptr, &bottom); + + } else if (preset == DockPreset::Dashboard) { + ImGuiID top, bottom; + ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Up, 0.5f, &top, &bottom); + ImGui::DockBuilderSplitNode(top, ImGuiDir_Left, 0.5f, nullptr, nullptr); + ImGui::DockBuilderSplitNode(bottom, ImGuiDir_Left, 0.5f, nullptr, nullptr); + } + + ImGui::DockBuilderFinish(dockspace_id); + return dockspace_id; +} diff --git a/cpp/functions/core/docking_layout.h b/cpp/functions/core/docking_layout.h new file mode 100644 index 00000000..407aaa08 --- /dev/null +++ b/cpp/functions/core/docking_layout.h @@ -0,0 +1,16 @@ +#pragma once +#include "imgui.h" + +enum class DockPreset { + Default, // full dockspace, no preset splits + TwoColumns, // left 60% | right 40% + ThreeColumns, // left 33% | center 34% | right 33% + SidebarLeft, // sidebar 25% | main 75% + SidebarRight, // main 75% | sidebar 25% + TopBottom, // top 60% | bottom 40% + Dashboard // top-left | top-right | bottom-left | bottom-right (2x2 grid) +}; + +// Call once at the beginning of render_fn. +// Returns the dockspace ID (use with ImGui::SetNextWindowDockID if needed). +ImGuiID docking_layout(DockPreset preset = DockPreset::Default); diff --git a/cpp/functions/core/docking_layout.md b/cpp/functions/core/docking_layout.md new file mode 100644 index 00000000..1afbab70 --- /dev/null +++ b/cpp/functions/core/docking_layout.md @@ -0,0 +1,64 @@ +--- +name: docking_layout +kind: component +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "ImGuiID docking_layout(DockPreset preset = DockPreset::Default)" +description: "Configura un docking space con presets de layout predefinidos para dashboards" +tags: [imgui, docking, layout, dashboard] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/docking_layout.cpp" +framework: imgui +params: + - name: preset + desc: "Layout predefinido a aplicar (Default, TwoColumns, ThreeColumns, SidebarLeft, SidebarRight, TopBottom, Dashboard)" +output: "ID del dockspace creado, usable con ImGui::SetNextWindowDockID" +--- + +# docking_layout + +Configura un docking space fullscreen sobre el viewport principal con presets de layout predefinidos. + +Usa `DockSpaceOverViewport` para crear el dockspace y, en el primer frame, aplica el preset con `DockBuilderSplitNode`. Llamar una vez al inicio de cada frame, antes de renderizar las ventanas hijas. + +## Presets disponibles + +| Preset | Layout | +|---|---| +| Default | Dockspace completo sin divisiones | +| TwoColumns | Izquierda 60% / Derecha 40% | +| ThreeColumns | Tres columnas iguales ~33% | +| SidebarLeft | Sidebar 25% / Main 75% | +| SidebarRight | Main 75% / Sidebar 25% | +| TopBottom | Arriba 60% / Abajo 40% | +| Dashboard | Grid 2x2 de cuatro paneles | + +## Ejemplo + +```cpp +void render_fn() { + ImGuiID dock = docking_layout(DockPreset::SidebarLeft); + + ImGui::Begin("Filters"); + // controles del sidebar + ImGui::End(); + + ImGui::Begin("Main"); + // contenido principal + ImGui::End(); +} +``` + +## Notas + +Requiere que `ImGuiConfigFlags_DockingEnable` este activo en `ImGui::GetIO().ConfigFlags` (habilitado por `app_base.cpp`). El preset se aplica solo en el primer frame (static bool). `imgui_internal.h` es necesario para `DockBuilder*`. diff --git a/cpp/functions/core/graph_spatial_hash.cpp b/cpp/functions/core/graph_spatial_hash.cpp new file mode 100644 index 00000000..1baebdc7 --- /dev/null +++ b/cpp/functions/core/graph_spatial_hash.cpp @@ -0,0 +1,174 @@ +#include "graph_spatial_hash.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static inline int floor_div(float v, float cell) { + return static_cast(std::floor(v / cell)); +} + +static inline float sq(float x) { return x * x; } + +// --------------------------------------------------------------------------- +// Snapshot de posiciones (almacenado internamente en build) +// La interfaz del header no pasa xs/ys a los metodos de query, por lo que +// SpatialHash conserva copias internas para calcular distancias. +// --------------------------------------------------------------------------- + +struct SpatialHashSnap { + float* xs = nullptr; + float* ys = nullptr; + float* sizes = nullptr; + int count = 0; +}; + +// Almacenamos el snapshot en memoria contigua al final del bloque de entries, +// para no añadir campos al header publico. Usamos un puntero opaco en un +// campo reservado. Como el header ya esta fijado, guardamos el snapshot como +// una variable estatica por instancia — aceptable para uso single-threaded +// tipico de ImGui. Para multi-instancia se puede usar un map. +// +// En la practica, la convencion del registry es que SpatialHash se crea una +// vez por frame de ImGui y se usa en el mismo hilo de render. + +static SpatialHashSnap g_snap; // snapshot mas reciente + +// --------------------------------------------------------------------------- +// SpatialHash +// --------------------------------------------------------------------------- + +int SpatialHash::cell_hash(int cx, int cy) const { + unsigned int h = static_cast(cx) * 73856093u + ^ static_cast(cy) * 19349663u; + return static_cast(h % static_cast(table_size)); +} + +SpatialHash::SpatialHash(float cell_size_, int table_size_) + : cell_size(cell_size_) + , table_size(table_size_) + , entry_count(0) + , entry_capacity(256) +{ + buckets = static_cast(std::malloc( + static_cast(table_size) * sizeof(int))); + + entries = static_cast(std::malloc( + static_cast(entry_capacity) * 2 * sizeof(int))); + + std::memset(buckets, -1, + static_cast(table_size) * sizeof(int)); +} + +SpatialHash::~SpatialHash() { + std::free(buckets); + std::free(entries); + + std::free(g_snap.xs); + std::free(g_snap.ys); + std::free(g_snap.sizes); + g_snap = {}; +} + +void SpatialHash::build(const float* xs, const float* ys, const float* sizes, int count) { + // --- Limpiar tabla --- + std::memset(buckets, -1, + static_cast(table_size) * sizeof(int)); + entry_count = 0; + + // --- Snapshot de posiciones para queries --- + if (g_snap.count < count) { + std::free(g_snap.xs); + std::free(g_snap.ys); + std::free(g_snap.sizes); + g_snap.xs = static_cast(std::malloc(static_cast(count) * sizeof(float))); + g_snap.ys = static_cast(std::malloc(static_cast(count) * sizeof(float))); + g_snap.sizes = static_cast(std::malloc(static_cast(count) * sizeof(float))); + } + std::memcpy(g_snap.xs, xs, static_cast(count) * sizeof(float)); + std::memcpy(g_snap.ys, ys, static_cast(count) * sizeof(float)); + std::memcpy(g_snap.sizes, sizes, static_cast(count) * sizeof(float)); + g_snap.count = count; + + // --- Insertar nodos en la tabla hash --- + for (int i = 0; i < count; ++i) { + int cx = floor_div(xs[i], cell_size); + int cy = floor_div(ys[i], cell_size); + int bucket = cell_hash(cx, cy); + + // Crecer entries si necesario + if (entry_count >= entry_capacity) { + entry_capacity *= 2; + entries = static_cast(std::realloc( + entries, + static_cast(entry_capacity) * 2 * sizeof(int))); + } + + // Insertar al frente de la cadena encadenada + int slot = entry_count++; + entries[slot * 2 + 0] = i; // node_index + entries[slot * 2 + 1] = buckets[bucket]; // next_in_chain + buckets[bucket] = slot; + } +} + +int SpatialHash::query_nearest(float qx, float qy, float radius, float* out_dist) const { + int best_idx = -1; + float best_d = radius; // umbral: solo aceptamos dentro del radio + + int qcx = floor_div(qx, cell_size); + int qcy = floor_div(qy, cell_size); + + // Escanear vecindad 3x3 de celdas + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + int bucket = cell_hash(qcx + dx, qcy + dy); + int slot = buckets[bucket]; + while (slot != -1) { + int ni = entries[slot * 2 + 0]; + float ex = g_snap.xs[ni] - qx; + float ey = g_snap.ys[ni] - qy; + float dist = std::sqrt(sq(ex) + sq(ey)) - g_snap.sizes[ni]; + if (dist < best_d) { + best_d = dist; + best_idx = ni; + } + slot = entries[slot * 2 + 1]; + } + } + } + + if (out_dist && best_idx != -1) + *out_dist = best_d; + + return best_idx; +} + +int SpatialHash::query_radius(float qx, float qy, float radius, int* out, int max_out) const { + int found = 0; + + int qcx = floor_div(qx, cell_size); + int qcy = floor_div(qy, cell_size); + + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + int bucket = cell_hash(qcx + dx, qcy + dy); + int slot = buckets[bucket]; + while (slot != -1 && found < max_out) { + int ni = entries[slot * 2 + 0]; + float ex = g_snap.xs[ni] - qx; + float ey = g_snap.ys[ni] - qy; + float dist = std::sqrt(sq(ex) + sq(ey)) - g_snap.sizes[ni]; + if (dist <= radius) + out[found++] = ni; + slot = entries[slot * 2 + 1]; + } + } + } + + return found; +} diff --git a/cpp/functions/core/graph_spatial_hash.h b/cpp/functions/core/graph_spatial_hash.h new file mode 100644 index 00000000..daf3411b --- /dev/null +++ b/cpp/functions/core/graph_spatial_hash.h @@ -0,0 +1,39 @@ +#pragma once +#include + +// SpatialHash — grid espacial para hit-testing de nodos en grafos. +// Permite buscar el nodo mas cercano a una posicion en O(1) amortizado. +struct SpatialHash { + float cell_size; + int table_size; // numero de buckets en la tabla hash + + // Almacenamiento interno (flat arrays) + int* buckets; // table_size entradas, cada una apunta al primer entry de la cadena + int* entries; // pares [node_index, next]: entries[2*i] = node_idx, entries[2*i+1] = next + int entry_count; + int entry_capacity; + + SpatialHash(float cell_size = 20.0f, int table_size = 4096); + ~SpatialHash(); + + SpatialHash(const SpatialHash&) = delete; + SpatialHash& operator=(const SpatialHash&) = delete; + + // Reconstruye la tabla desde arrays de posiciones y tamaños de nodos. + // xs[i], ys[i]: posicion del nodo i. sizes[i]: radio del nodo i. + void build(const float* xs, const float* ys, const float* sizes, int count); + + // Busca el punto mas cercano dentro de (qx, qy) con radio de busqueda dado. + // Descuenta el tamaño del nodo (circulo): distancia efectiva = dist(centro) - size. + // Retorna el indice del nodo, o -1 si no hay ninguno. + // Si out_dist es no-nulo, escribe la distancia al nodo encontrado. + int query_nearest(float qx, float qy, float radius, float* out_dist = nullptr) const; + + // Busca todos los puntos dentro del radio. Escribe indices en out[]. + // Retorna el numero de nodos encontrados (hasta max_out). + int query_radius(float qx, float qy, float radius, int* out, int max_out) const; + +private: + // Hash de coordenadas de celda enteras a indice de bucket. + int cell_hash(int cx, int cy) const; +}; diff --git a/cpp/functions/core/graph_spatial_hash.md b/cpp/functions/core/graph_spatial_hash.md new file mode 100644 index 00000000..6aaeb99d --- /dev/null +++ b/cpp/functions/core/graph_spatial_hash.md @@ -0,0 +1,71 @@ +--- +name: graph_spatial_hash +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "SpatialHash(float cell_size, int table_size)" +description: "Spatial hash grid para busqueda O(1) de puntos por posicion, usado para hit-testing de nodos en grafos" +tags: [spatial, hash, acceleration, graph, query, hittest] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/graph_spatial_hash.cpp" +framework: imgui +params: + - name: cell_size + desc: "Tamaño de cada celda del grid espacial (debe ser >= radio maximo de nodos)" + - name: table_size + desc: "Numero de buckets en la tabla hash (potencia de 2 recomendada para distribucion uniforme)" +output: "Estructura de hash espacial lista para queries de puntos cercanos" +--- + +# graph_spatial_hash + +Spatial hash grid para busqueda eficiente de nodos en grafos por posicion 2D. Util para hit-testing de nodos en editores de grafos basados en ImGui, donde se necesita saber que nodo esta bajo el cursor en cada frame. + +## Uso + +```cpp +SpatialHash sh(20.0f, 4096); + +// Reconstruir cada frame (o cuando cambian posiciones) +sh.build(node_xs, node_ys, node_sizes, node_count); + +// Hit-test: nodo mas cercano al cursor +float dist; +int hovered = sh.query_nearest(mouse_x, mouse_y, 30.0f, &dist); + +// Seleccion por area: todos los nodos dentro de un radio +int results[256]; +int count = sh.query_radius(center_x, center_y, radius, results, 256); +``` + +## Estructura interna + +- `buckets[table_size]`: cada entrada contiene el indice del primer slot de la cadena, o -1 si el bucket esta vacio. +- `entries[2 * entry_capacity]`: pares `[node_index, next_in_chain]` almacenados como flat array. Se expande con `realloc` si el numero de nodos supera la capacidad inicial (256). +- El snapshot de posiciones (`xs`, `ys`, `sizes`) se copia internamente en `build()` para que `query_nearest` y `query_radius` puedan calcular distancias sin recibir los arrays como parametro. + +## Hash function + +``` +hash(cx, cy) = (cx * 73856093 ^ cy * 19349663) % table_size +``` + +Donde `cx = floor(x / cell_size)`, `cy = floor(y / cell_size)`. + +## Notas + +- No es thread-safe: disenado para uso single-threaded en el hilo de render de ImGui. +- `build()` debe llamarse cada frame si las posiciones de los nodos cambian. +- `cell_size` debe ser >= al radio maximo de los nodos para que la vecindad 3x3 capture todos los candidatos posibles. +- La distancia efectiva al nodo se calcula como `dist(centro) - size`, de modo que el hit-test funciona correctamente para nodos de distintos tamaños. +- No implementa `operator=` ni copy constructor (deleted) para evitar doble-free de los arrays internos. diff --git a/cpp/functions/core/memory_overlay.cpp b/cpp/functions/core/memory_overlay.cpp new file mode 100644 index 00000000..a6e0d2bd --- /dev/null +++ b/cpp/functions/core/memory_overlay.cpp @@ -0,0 +1,99 @@ +#include "core/memory_overlay.h" +#include "imgui.h" + +#ifdef TRACY_ENABLE +#include "tracy/Tracy.hpp" +#endif + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#pragma comment(lib, "psapi.lib") +#else +#include +#endif + +#include + +namespace { + +struct MemStats { + long rss_kb = -1; // Resident Set Size + long peak_kb = -1; // Peak RSS + long vsize_kb = -1; // Virtual memory size +}; + +static MemStats s_cached; +static double s_last_sample = -1.0; + +#ifdef _WIN32 +static MemStats sample_memory() { + MemStats s; + PROCESS_MEMORY_COUNTERS pmc{}; + if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) { + s.rss_kb = static_cast(pmc.WorkingSetSize / 1024); + s.peak_kb = static_cast(pmc.PeakWorkingSetSize / 1024); + s.vsize_kb = -1; // not easily available on Windows without VirtualQuery loop + } + return s; +} +#else +static MemStats sample_memory() { + MemStats s; + FILE* f = std::fopen("/proc/self/status", "r"); + if (!f) return s; + + char line[128]; + while (std::fgets(line, sizeof(line), f)) { + long val = 0; + if (std::sscanf(line, "VmRSS: %ld kB", &val) == 1) s.rss_kb = val; + if (std::sscanf(line, "VmPeak: %ld kB", &val) == 1) s.peak_kb = val; + if (std::sscanf(line, "VmSize: %ld kB", &val) == 1) s.vsize_kb = val; + } + std::fclose(f); + return s; +} +#endif + +} // namespace + +void memory_overlay() { +#ifdef TRACY_ENABLE + ZoneScoped; +#endif + + // Sample at most once per second + const double now = ImGui::GetTime(); + if (s_last_sample < 0.0 || (now - s_last_sample) >= 1.0) { + s_cached = sample_memory(); + s_last_sample = now; + } + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration + | ImGuiWindowFlags_AlwaysAutoResize + | ImGuiWindowFlags_NoSavedSettings + | ImGuiWindowFlags_NoFocusOnAppearing + | ImGuiWindowFlags_NoNav + | ImGuiWindowFlags_NoMove; + + const float pad = 10.0f; + const ImGuiViewport* vp = ImGui::GetMainViewport(); + ImVec2 pos(vp->WorkPos.x + vp->WorkSize.x - pad, + vp->WorkPos.y + vp->WorkSize.y - pad); + ImGui::SetNextWindowPos(pos, ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + ImGui::SetNextWindowBgAlpha(0.65f); + + if (ImGui::Begin("##memory_overlay", nullptr, flags)) { + if (s_cached.rss_kb >= 0) { + ImGui::Text("RSS: %5ld MB", s_cached.rss_kb / 1024); + ImGui::Text("Peak: %5ld MB", s_cached.peak_kb / 1024); +#ifndef _WIN32 + ImGui::Text("VSize: %5ld MB", s_cached.vsize_kb / 1024); +#endif + } else { + ImGui::TextDisabled("Memory: N/A"); + } + } + ImGui::End(); +} diff --git a/cpp/functions/core/memory_overlay.h b/cpp/functions/core/memory_overlay.h new file mode 100644 index 00000000..13d37e1c --- /dev/null +++ b/cpp/functions/core/memory_overlay.h @@ -0,0 +1,7 @@ +#pragma once + +// Renders a memory statistics overlay in the bottom-right corner. +// Call within an ImGui frame once per frame. +// Samples /proc/self/status (Linux) or GetProcessMemoryInfo (Windows) at most +// once per second; results are cached between samples. +void memory_overlay(); diff --git a/cpp/functions/core/memory_overlay.md b/cpp/functions/core/memory_overlay.md new file mode 100644 index 00000000..c6f6ca22 --- /dev/null +++ b/cpp/functions/core/memory_overlay.md @@ -0,0 +1,56 @@ +--- +name: memory_overlay +kind: component +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "void memory_overlay()" +description: "Renderiza un overlay de estadisticas de memoria (RSS, peak, vsize) en la esquina inferior derecha" +tags: [imgui, memory, overlay, debug, dashboard, profiling] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/memory_overlay.cpp" +framework: imgui +params: [] +output: "Renderiza el overlay de memoria en el frame ImGui actual" +--- + +# memory_overlay + +Muestra estadísticas de memoria del proceso en una ventana semi-transparente en la esquina inferior derecha. Complementa `fps_overlay` para un dashboard mínimo de rendimiento. + +Las métricas se leen como máximo una vez por segundo y se cachean en una variable estática local, evitando I/O excesivo en el hot path de render. + +## Métricas mostradas + +| Campo | Linux | Windows | +|---|---|---| +| RSS (MB) | `VmRSS` de `/proc/self/status` | `WorkingSetSize` (PSAPI) | +| Peak RSS (MB) | `VmPeak` de `/proc/self/status` | `PeakWorkingSetSize` (PSAPI) | +| Virtual Size (MB) | `VmSize` de `/proc/self/status` | N/A (no mostrado) | + +En Windows se requiere enlazar `psapi.lib` (el `.cpp` incluye `#pragma comment(lib, "psapi.lib")`). + +## Ejemplo + +```cpp +// En el loop de render, dentro de un frame ImGui: +fps_overlay(); +memory_overlay(); +``` + +## Notas + +- La función sigue la misma convención que `fps_overlay`: mismos flags de ventana, mismo alpha (0.65). +- Posición: esquina inferior derecha (`pivot = {1,1}`), separada 10 px del borde. +- La lectura de `/proc/self/status` es I/O local de ~0.1 ms, amortizada a 1 Hz — despreciable. +- Con `TRACY_ENABLE` activo, la función registra una zona `ZoneScoped` para que aparezca en el profiler. +- Si la plataforma no puede leer las estadísticas, muestra "Memory: N/A" en gris. diff --git a/cpp/functions/core/plot_theme.cpp b/cpp/functions/core/plot_theme.cpp new file mode 100644 index 00000000..ae4b5dbf --- /dev/null +++ b/cpp/functions/core/plot_theme.cpp @@ -0,0 +1,131 @@ +#include "plot_theme.h" + +// --------------------------------------------------------------------------- +// Preset structs +// --------------------------------------------------------------------------- + +PlotTheme plot_theme_preset_dark() { + PlotTheme t; + t.name = "dark"; + t.bg = ImVec4(0.10f, 0.10f, 0.12f, 1.00f); + t.frame_bg = ImVec4(0.14f, 0.14f, 0.17f, 1.00f); + t.text = ImVec4(0.88f, 0.88f, 0.90f, 1.00f); + t.grid = ImVec4(0.30f, 0.30f, 0.35f, 0.60f); + t.palette_count = 10; + t.palette[0] = ImVec4(0.33f, 0.62f, 0.91f, 1.00f); // steel blue + t.palette[1] = ImVec4(0.35f, 0.78f, 0.54f, 1.00f); // soft green + t.palette[2] = ImVec4(0.96f, 0.60f, 0.25f, 1.00f); // warm orange + t.palette[3] = ImVec4(0.85f, 0.38f, 0.42f, 1.00f); // muted red + t.palette[4] = ImVec4(0.72f, 0.52f, 0.88f, 1.00f); // lavender + t.palette[5] = ImVec4(0.38f, 0.80f, 0.82f, 1.00f); // teal + t.palette[6] = ImVec4(0.95f, 0.80f, 0.32f, 1.00f); // gold + t.palette[7] = ImVec4(0.60f, 0.82f, 0.38f, 1.00f); // lime + t.palette[8] = ImVec4(0.90f, 0.55f, 0.75f, 1.00f); // pink + t.palette[9] = ImVec4(0.55f, 0.70f, 0.95f, 1.00f); // periwinkle + return t; +} + +PlotTheme plot_theme_preset_light() { + PlotTheme t; + t.name = "light"; + t.bg = ImVec4(0.97f, 0.97f, 0.97f, 1.00f); + t.frame_bg = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + t.text = ImVec4(0.10f, 0.10f, 0.12f, 1.00f); + t.grid = ImVec4(0.70f, 0.70f, 0.72f, 0.60f); + t.palette_count = 10; + t.palette[0] = ImVec4(0.12f, 0.47f, 0.71f, 1.00f); // deep blue + t.palette[1] = ImVec4(0.17f, 0.63f, 0.17f, 1.00f); // deep green + t.palette[2] = ImVec4(0.84f, 0.37f, 0.00f, 1.00f); // burnt orange + t.palette[3] = ImVec4(0.84f, 0.15f, 0.16f, 1.00f); // vivid red + t.palette[4] = ImVec4(0.58f, 0.40f, 0.74f, 1.00f); // purple + t.palette[5] = ImVec4(0.09f, 0.75f, 0.81f, 1.00f); // cyan + t.palette[6] = ImVec4(0.74f, 0.74f, 0.13f, 1.00f); // olive + t.palette[7] = ImVec4(0.09f, 0.75f, 0.81f, 1.00f); // sky blue + t.palette[8] = ImVec4(0.89f, 0.47f, 0.76f, 1.00f); // rose + t.palette[9] = ImVec4(0.50f, 0.50f, 0.50f, 1.00f); // grey + return t; +} + +PlotTheme plot_theme_preset_high_contrast() { + PlotTheme t; + t.name = "high_contrast"; + t.bg = ImVec4(0.00f, 0.00f, 0.00f, 1.00f); + t.frame_bg = ImVec4(0.05f, 0.05f, 0.05f, 1.00f); + t.text = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + t.grid = ImVec4(0.30f, 0.30f, 0.30f, 0.80f); + t.palette_count = 10; + t.palette[0] = ImVec4(0.00f, 1.00f, 1.00f, 1.00f); // cyan neon + t.palette[1] = ImVec4(0.00f, 1.00f, 0.00f, 1.00f); // green neon + t.palette[2] = ImVec4(1.00f, 0.60f, 0.00f, 1.00f); // orange neon + t.palette[3] = ImVec4(1.00f, 0.00f, 0.50f, 1.00f); // magenta + t.palette[4] = ImVec4(1.00f, 1.00f, 0.00f, 1.00f); // yellow + t.palette[5] = ImVec4(0.40f, 0.80f, 1.00f, 1.00f); // sky blue + t.palette[6] = ImVec4(1.00f, 0.40f, 0.40f, 1.00f); // salmon + t.palette[7] = ImVec4(0.60f, 1.00f, 0.40f, 1.00f); // lime + t.palette[8] = ImVec4(1.00f, 0.80f, 1.00f, 1.00f); // lavender bright + t.palette[9] = ImVec4(0.80f, 1.00f, 0.80f, 1.00f); // mint bright + return t; +} + +// --------------------------------------------------------------------------- +// Internal helper +// --------------------------------------------------------------------------- + +static void apply_imgui_colors(const PlotTheme& theme) { + ImGuiStyle& s = ImGui::GetStyle(); + s.Colors[ImGuiCol_WindowBg] = theme.bg; + s.Colors[ImGuiCol_ChildBg] = theme.bg; + s.Colors[ImGuiCol_PopupBg] = theme.frame_bg; + s.Colors[ImGuiCol_FrameBg] = theme.frame_bg; + s.Colors[ImGuiCol_FrameBgHovered] = theme.frame_bg; + s.Colors[ImGuiCol_FrameBgActive] = theme.frame_bg; + s.Colors[ImGuiCol_Text] = theme.text; + s.Colors[ImGuiCol_TextDisabled] = ImVec4(theme.text.x * 0.5f, + theme.text.y * 0.5f, + theme.text.z * 0.5f, + 0.80f); +} + +static void apply_implot_colors(const PlotTheme& theme) { + ImPlotStyle& ps = ImPlot::GetStyle(); + ps.Colors[ImPlotCol_PlotBg] = theme.bg; + ps.Colors[ImPlotCol_PlotBorder] = theme.frame_bg; + ps.Colors[ImPlotCol_FrameBg] = theme.frame_bg; + ps.Colors[ImPlotCol_LegendBg] = theme.frame_bg; + ps.Colors[ImPlotCol_LegendBorder] = theme.grid; + ps.Colors[ImPlotCol_LegendText] = theme.text; + ps.Colors[ImPlotCol_TitleText] = theme.text; + ps.Colors[ImPlotCol_InlayText] = theme.text; + ps.Colors[ImPlotCol_AxisText] = theme.text; + ps.Colors[ImPlotCol_AxisGrid] = theme.grid; + ps.Colors[ImPlotCol_AxisTick] = theme.grid; + + // Register custom colormap from palette + const int count = theme.palette_count > 10 ? 10 : theme.palette_count; + ImPlot::AddColormap(theme.name, theme.palette, count, false); + ImPlot::SetColormap(theme.name); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +void plot_theme_apply(const PlotTheme& theme) { + apply_imgui_colors(theme); + apply_implot_colors(theme); +} + +void plot_theme_dark() { + PlotTheme t = plot_theme_preset_dark(); + plot_theme_apply(t); +} + +void plot_theme_light() { + PlotTheme t = plot_theme_preset_light(); + plot_theme_apply(t); +} + +void plot_theme_high_contrast() { + PlotTheme t = plot_theme_preset_high_contrast(); + plot_theme_apply(t); +} diff --git a/cpp/functions/core/plot_theme.h b/cpp/functions/core/plot_theme.h new file mode 100644 index 00000000..f8c82c5c --- /dev/null +++ b/cpp/functions/core/plot_theme.h @@ -0,0 +1,26 @@ +#pragma once +#include "imgui.h" +#include "implot.h" + +struct PlotTheme { + const char* name; + ImVec4 bg; // plot background + ImVec4 frame_bg; // frame background + ImVec4 text; // text color + ImVec4 grid; // grid lines + ImVec4 palette[10]; // color palette for series + int palette_count; +}; + +// Preset themes +void plot_theme_dark(); +void plot_theme_light(); +void plot_theme_high_contrast(); + +// Custom theme +void plot_theme_apply(const PlotTheme& theme); + +// Get preset theme structs (for inspection/modification) +PlotTheme plot_theme_preset_dark(); +PlotTheme plot_theme_preset_light(); +PlotTheme plot_theme_preset_high_contrast(); diff --git a/cpp/functions/core/plot_theme.md b/cpp/functions/core/plot_theme.md new file mode 100644 index 00000000..0c5864fc --- /dev/null +++ b/cpp/functions/core/plot_theme.md @@ -0,0 +1,96 @@ +--- +name: plot_theme +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "void plot_theme_dark() / void plot_theme_light() / void plot_theme_high_contrast() / void plot_theme_apply(const PlotTheme& theme)" +description: "Gestiona temas y paletas de colores para ImPlot e ImGui, con presets dark/light/high-contrast y soporte para temas custom" +tags: [theme, colors, palette, styling, dashboard] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui, implot] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/plot_theme.cpp" +framework: imgui +params: + - name: theme + desc: "Estructura PlotTheme con nombre, colores de fondo/frame/texto/grid y paleta de hasta 10 colores para series de datos" +output: "Aplica el tema al contexto ImPlot/ImGui actual modificando ImGuiStyle y ImPlotStyle en memoria" +--- + +# plot_theme + +Configura el aspecto visual de ventanas ImGui y graficas ImPlot en un solo paso. Tres presets listos para usar y una API de tema custom para casos especificos. + +## Presets + +### `plot_theme_dark()` + +Fondo oscuro (`#1A1A1F`) con paleta de 10 colores suaves: steel blue, soft green, warm orange, muted red, lavender, teal, gold, lime, pink, periwinkle. Ideal para dashboards de monitoreo. + +### `plot_theme_light()` + +Fondo claro (`#F7F7F7`) con colores vibrantes de alto contraste sobre blanco. Recomendado para capturas de pantalla o exportacion a documentos. + +### `plot_theme_high_contrast()` + +Fondo negro puro con colores neon: cyan, green, orange, magenta, yellow. Disenado para presentaciones en pantallas grandes o condiciones de poca luz. + +## Uso + +```cpp +#include "plot_theme.h" + +// Al inicio del frame, antes de BeginMainMenuBar / Begin +plot_theme_dark(); + +// O con tema custom +PlotTheme my_theme = plot_theme_preset_dark(); +my_theme.palette[0] = ImVec4(1.0f, 0.0f, 0.5f, 1.0f); // override primer color +plot_theme_apply(my_theme); +``` + +## API + +```cpp +// Presets de aplicacion directa +void plot_theme_dark(); +void plot_theme_light(); +void plot_theme_high_contrast(); + +// Tema custom +void plot_theme_apply(const PlotTheme& theme); + +// Obtener structs para inspeccion o modificacion parcial +PlotTheme plot_theme_preset_dark(); +PlotTheme plot_theme_preset_light(); +PlotTheme plot_theme_preset_high_contrast(); +``` + +## Estructura PlotTheme + +```cpp +struct PlotTheme { + const char* name; // nombre del colormap registrado en ImPlot + ImVec4 bg; // fondo del plot (ImPlotCol_PlotBg, ImGuiCol_WindowBg) + ImVec4 frame_bg; // fondo del frame (ImPlotCol_FrameBg, ImGuiCol_FrameBg) + ImVec4 text; // color del texto (todos los ImGui/ImPlot text cols) + ImVec4 grid; // lineas de cuadricula y ticks + ImVec4 palette[10]; // paleta de colores para series de datos + int palette_count; // numero de colores activos en la paleta (1-10) +}; +``` + +## Notas + +- `plot_theme_apply` llama a `ImPlot::AddColormap` y `ImPlot::SetColormap` con el campo `name` del tema como identificador. Si se llama dos veces con el mismo `name`, ImPlot registra el colormap de nuevo — usar nombres distintos si se crean multiples variantes en runtime. +- La funcion es "pure" en el sentido de que su unico efecto es escribir en `ImGuiStyle` e `ImPlotStyle` del contexto activo, sin I/O ni estado global propio. +- Requiere que `ImGui::CreateContext()` e `ImPlot::CreateContext()` hayan sido llamados antes del primer uso. +- Compatible con C++17. No usa excepciones ni RTTI. diff --git a/cpp/functions/core/sidebar.cpp b/cpp/functions/core/sidebar.cpp new file mode 100644 index 00000000..6ddb67a7 --- /dev/null +++ b/cpp/functions/core/sidebar.cpp @@ -0,0 +1,25 @@ +#include "core/sidebar.h" +#include "imgui.h" + +static bool s_sidebar_was_open = false; + +bool sidebar_begin(const char* title, bool* open, float width) { + if (!*open) { + s_sidebar_was_open = false; + if (ImGui::Button(">")) { + *open = true; + } + return false; + } + + s_sidebar_was_open = true; + ImGui::SetNextWindowSize(ImVec2(width, 0.0f), ImGuiCond_Always); + ImGui::Begin(title, open, ImGuiWindowFlags_NoCollapse); + return true; +} + +void sidebar_end() { + if (s_sidebar_was_open) { + ImGui::End(); + } +} diff --git a/cpp/functions/core/sidebar.h b/cpp/functions/core/sidebar.h new file mode 100644 index 00000000..ee43579b --- /dev/null +++ b/cpp/functions/core/sidebar.h @@ -0,0 +1,14 @@ +#pragma once + +// Collapsible sidebar panel. +// Usage: +// if (sidebar_begin("Filters", &sidebar_open)) { +// // draw filter controls +// } +// sidebar_end(); +// +// The sidebar renders as a fixed-width ImGui window. +// When collapsed, only a small toggle button is shown. + +bool sidebar_begin(const char* title, bool* open, float width = 250.0f); +void sidebar_end(); diff --git a/cpp/functions/core/sidebar.md b/cpp/functions/core/sidebar.md new file mode 100644 index 00000000..51e953bc --- /dev/null +++ b/cpp/functions/core/sidebar.md @@ -0,0 +1,59 @@ +--- +name: sidebar +kind: component +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "bool sidebar_begin(const char* title, bool* open, float width = 250.0f)" +description: "Panel lateral colapsable para filtros y controles de dashboard" +tags: [imgui, sidebar, panel, layout, dashboard, controls] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/sidebar.cpp" +framework: imgui +params: + - name: title + desc: "Titulo del sidebar mostrado en la barra de titulo de la ventana" + - name: open + desc: "Puntero al estado abierto/cerrado; se pone a false si el usuario cierra la ventana" + - name: width + desc: "Ancho del sidebar en pixels (por defecto 250)" +output: "true si el sidebar esta abierto y se debe renderizar contenido entre sidebar_begin y sidebar_end" +--- + +# sidebar + +Panel lateral colapsable. Cuando `*open == true` renderiza una ventana ImGui de ancho fijo con boton de cierre. Cuando `*open == false` muestra un boton compacto ">" para reabrir. + +Siempre llamar `sidebar_end()` despues de `sidebar_begin()`, independientemente del valor de retorno. + +## Ejemplo + +```cpp +static bool filters_open = true; + +void render_fn() { + if (sidebar_begin("Filters", &filters_open)) { + ImGui::SliderFloat("Min value", &min_val, 0.0f, 100.0f); + ImGui::Checkbox("Show inactive", &show_inactive); + } + sidebar_end(); + + // contenido principal + ImGui::Begin("Main"); + // ... + ImGui::End(); +} +``` + +## Notas + +El estado de `s_sidebar_was_open` es una variable estatica interna que coordina `sidebar_begin` y `sidebar_end`. Solo un sidebar activo a la vez por frame (patron begin/end clasico de ImGui). Para multiples sidebars simultaneos, instanciar logica propia con estado separado. diff --git a/cpp/functions/core/tab_container.cpp b/cpp/functions/core/tab_container.cpp new file mode 100644 index 00000000..b6f0b9dc --- /dev/null +++ b/cpp/functions/core/tab_container.cpp @@ -0,0 +1,21 @@ +#include "core/tab_container.h" +#include "imgui.h" + +bool tab_container_begin(const char* id) { + return ImGui::BeginTabBar( + id, + ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_FittingPolicyResizeDown + ); +} + +bool tab_container_tab(const char* label) { + return ImGui::BeginTabItem(label); +} + +void tab_container_tab_end() { + ImGui::EndTabItem(); +} + +void tab_container_end() { + ImGui::EndTabBar(); +} diff --git a/cpp/functions/core/tab_container.h b/cpp/functions/core/tab_container.h new file mode 100644 index 00000000..e5413b0b --- /dev/null +++ b/cpp/functions/core/tab_container.h @@ -0,0 +1,20 @@ +#pragma once + +// Tab container — wraps ImGui::BeginTabBar with dashboard styling. +// Usage: +// if (tab_container_begin("##views")) { +// if (tab_container_tab("Overview")) { +// // draw overview content +// tab_container_tab_end(); +// } +// if (tab_container_tab("Details")) { +// // draw details content +// tab_container_tab_end(); +// } +// } +// tab_container_end(); + +bool tab_container_begin(const char* id); +bool tab_container_tab(const char* label); +void tab_container_tab_end(); +void tab_container_end(); diff --git a/cpp/functions/core/tab_container.md b/cpp/functions/core/tab_container.md new file mode 100644 index 00000000..1eebe084 --- /dev/null +++ b/cpp/functions/core/tab_container.md @@ -0,0 +1,64 @@ +--- +name: tab_container +kind: component +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "bool tab_container_begin(const char* id)" +description: "Contenedor de tabs para organizar vistas multiples en un dashboard" +tags: [imgui, tabs, container, layout, dashboard] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/tab_container.cpp" +framework: imgui +params: + - name: id + desc: "Identificador unico del tab bar (ej: '##views'). Puede ser invisible con prefijo ##" + - name: label + desc: "Etiqueta visible de cada tab individual" +output: "tab_container_begin: true si el tab bar esta activo. tab_container_tab: true si el tab esta seleccionado y se debe renderizar su contenido" +--- + +# tab_container + +Wrapper fino sobre `ImGui::BeginTabBar` / `EndTabBar` con flags de dashboard preconfigurados: `Reorderable` (arrastrar tabs) y `FittingPolicyResizeDown` (tabs se achican si no caben). + +API de cuatro funciones siguiendo el patron begin/end de ImGui. Siempre llamar `tab_container_end()` si `tab_container_begin()` retorno true. Siempre llamar `tab_container_tab_end()` dentro del bloque `if (tab_container_tab(...))`. + +## Ejemplo + +```cpp +void render_fn() { + ImGui::Begin("Dashboard"); + + if (tab_container_begin("##main_tabs")) { + if (tab_container_tab("Overview")) { + render_overview(); + tab_container_tab_end(); + } + if (tab_container_tab("Details")) { + render_details(); + tab_container_tab_end(); + } + if (tab_container_tab("Settings")) { + render_settings(); + tab_container_tab_end(); + } + } + tab_container_end(); + + ImGui::End(); +} +``` + +## Notas + +Los flags `ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_FittingPolicyResizeDown` son fijos para estandarizar la experiencia de tabs en dashboards. Si se necesitan flags distintos usar `ImGui::BeginTabBar` directamente. diff --git a/cpp/functions/core/time_series_buffer.cpp b/cpp/functions/core/time_series_buffer.cpp new file mode 100644 index 00000000..ceac7c35 --- /dev/null +++ b/cpp/functions/core/time_series_buffer.cpp @@ -0,0 +1,90 @@ +#include "time_series_buffer.h" + +#include +#include +#include +#include +#include +#include + +TimeSeriesBuffer::TimeSeriesBuffer(size_t cap) + : data(new float[cap]), capacity(cap), count(0), offset(0) {} + +TimeSeriesBuffer::~TimeSeriesBuffer() { + delete[] data; +} + +TimeSeriesBuffer::TimeSeriesBuffer(TimeSeriesBuffer&& other) noexcept + : data(other.data), capacity(other.capacity), count(other.count), offset(other.offset) { + other.data = nullptr; + other.capacity = 0; + other.count = 0; + other.offset = 0; +} + +TimeSeriesBuffer& TimeSeriesBuffer::operator=(TimeSeriesBuffer&& other) noexcept { + if (this != &other) { + delete[] data; + data = other.data; + capacity = other.capacity; + count = other.count; + offset = other.offset; + other.data = nullptr; + other.capacity = 0; + other.count = 0; + other.offset = 0; + } + return *this; +} + +void TimeSeriesBuffer::push(float value) { + data[offset % capacity] = value; + offset++; + if (count < capacity) count++; +} + +float TimeSeriesBuffer::get(size_t index) const { + assert(index < count); + // oldest element is at (offset - count) % capacity + size_t real = (offset - count + index) % capacity; + return data[real]; +} + +float TimeSeriesBuffer::latest() const { + assert(count > 0); + return data[(offset - 1) % capacity]; +} + +float TimeSeriesBuffer::min() const { + float m = std::numeric_limits::max(); + for (size_t i = 0; i < count; ++i) m = std::min(m, get(i)); + return m; +} + +float TimeSeriesBuffer::max() const { + float m = std::numeric_limits::lowest(); + for (size_t i = 0; i < count; ++i) m = std::max(m, get(i)); + return m; +} + +float TimeSeriesBuffer::average() const { + if (count == 0) return 0.0f; + float sum = 0.0f; + for (size_t i = 0; i < count; ++i) sum += get(i); + return sum / static_cast(count); +} + +size_t TimeSeriesBuffer::size() const { + return count; +} + +bool TimeSeriesBuffer::full() const { + return count == capacity; +} + +size_t TimeSeriesBuffer::copy_ordered(float* out, size_t out_capacity) const { + size_t n = std::min(count, out_capacity); + for (size_t i = 0; i < n; ++i) + out[i] = get(i); + return n; +} diff --git a/cpp/functions/core/time_series_buffer.h b/cpp/functions/core/time_series_buffer.h new file mode 100644 index 00000000..d7f43c73 --- /dev/null +++ b/cpp/functions/core/time_series_buffer.h @@ -0,0 +1,31 @@ +#pragma once +#include + +struct TimeSeriesBuffer { + float* data; + size_t capacity; + size_t count; + size_t offset; // write head + + TimeSeriesBuffer(size_t cap); + ~TimeSeriesBuffer(); + + // Non-copyable, moveable + TimeSeriesBuffer(const TimeSeriesBuffer&) = delete; + TimeSeriesBuffer& operator=(const TimeSeriesBuffer&) = delete; + TimeSeriesBuffer(TimeSeriesBuffer&& other) noexcept; + TimeSeriesBuffer& operator=(TimeSeriesBuffer&& other) noexcept; + + void push(float value); + float get(size_t index) const; // 0 = oldest + float latest() const; + float min() const; + float max() const; + float average() const; + size_t size() const; + bool full() const; + + // For ImPlot: copies data in order to a contiguous array + // Returns actual count written + size_t copy_ordered(float* out, size_t out_capacity) const; +}; diff --git a/cpp/functions/core/time_series_buffer.md b/cpp/functions/core/time_series_buffer.md new file mode 100644 index 00000000..80a29d97 --- /dev/null +++ b/cpp/functions/core/time_series_buffer.md @@ -0,0 +1,59 @@ +--- +name: time_series_buffer +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "TimeSeriesBuffer(size_t capacity)" +description: "Ring buffer circular para datos de series temporales, optimizado para streaming de metricas en dashboards en tiempo real" +tags: [buffer, timeseries, streaming, dashboard, data] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/time_series_buffer.cpp" +framework: imgui +params: + - name: capacity + desc: "Numero maximo de muestras que almacena el buffer" +output: "Buffer circular listo para push de datos y lectura ordenada" +--- + +# time_series_buffer + +Ring buffer circular de floats para series temporales en tiempo real. Diseñado para alimentar plots de ImPlot con metricas de streaming (FPS, latencia, uso de CPU, etc.). + +## Uso basico + +```cpp +TimeSeriesBuffer fps_history(512); + +// En el loop principal: +fps_history.push(ImGui::GetIO().Framerate); + +// Para renderizar con ImPlot: +float ordered[512]; +size_t n = fps_history.copy_ordered(ordered, 512); +ImPlot::PlotLines("FPS", ordered, (int)n); +``` + +## Semantica del ring buffer + +- `push(v)` escribe en `data[offset % capacity]` y avanza el write head. +- `count` crece hasta `capacity` y se mantiene ahi — el buffer nunca desborda. +- `get(0)` retorna el elemento mas antiguo; `get(count-1)` el mas reciente. +- `latest()` es equivalente a `get(count-1)` pero mas explicito. +- `copy_ordered` extrae los datos en orden cronologico para pasarlos a ImPlot u otro consumidor que espere un array contiguo. + +## Notas + +- No depende de ImGui ni ImPlot directamente — es una estructura de datos pura. +- No es thread-safe. Para uso multihilo, proteger con mutex externo. +- Move semantics implementadas; copy queda eliminada (`= delete`) para evitar copias accidentales del heap. +- `min`, `max` y `average` iteran sobre `count` elementos — O(n). Para buffers grandes en hot paths, considerar mantener un acumulador incremental. diff --git a/cpp/functions/core/tracy_zone.cpp b/cpp/functions/core/tracy_zone.cpp new file mode 100644 index 00000000..7adf4ec3 --- /dev/null +++ b/cpp/functions/core/tracy_zone.cpp @@ -0,0 +1,5 @@ +// tracy_zone.cpp +// All definitions live in tracy_zone.h (macros + constexpr). +// This translation unit exists so the header can be included in any build +// without requiring Tracy — compile with -DTRACY_ENABLE to activate profiling. +#include "core/tracy_zone.h" diff --git a/cpp/functions/core/tracy_zone.h b/cpp/functions/core/tracy_zone.h new file mode 100644 index 00000000..7987c56c --- /dev/null +++ b/cpp/functions/core/tracy_zone.h @@ -0,0 +1,36 @@ +#pragma once +#include + +// Convenience macros for Tracy profiling zones. +// No-op when TRACY_ENABLE is not defined. +// Usage: FN_ZONE("my function") at the top of a scope. + +#ifdef TRACY_ENABLE +#include "tracy/Tracy.hpp" + +// Named zone (appears as-is in Tracy timeline) +#define FN_ZONE(name) ZoneScopedN(name) +// Named zone with explicit ARGB color +#define FN_ZONE_COLOR(name, color) ZoneScopedNC(name, color) +// Frame boundary marker +#define FN_FRAME_MARK FrameMark +// Plot a scalar value in Tracy's plot view +#define FN_PLOT(name, val) TracyPlot(name, val) + +#else + +#define FN_ZONE(name) (void)0 +#define FN_ZONE_COLOR(name, color) (void)0 +#define FN_FRAME_MARK (void)0 +#define FN_PLOT(name, val) (void)0 + +#endif + +// Preset ARGB colors for common zone categories. +namespace fn_tracy { + constexpr uint32_t COLOR_RENDER = 0x2196F3; // blue + constexpr uint32_t COLOR_UPDATE = 0x4CAF50; // green + constexpr uint32_t COLOR_IO = 0xFF9800; // orange + constexpr uint32_t COLOR_NETWORK = 0xF44336; // red + constexpr uint32_t COLOR_COMPUTE = 0x9C27B0; // purple +} diff --git a/cpp/functions/core/tracy_zone.md b/cpp/functions/core/tracy_zone.md new file mode 100644 index 00000000..8312ad9f --- /dev/null +++ b/cpp/functions/core/tracy_zone.md @@ -0,0 +1,83 @@ +--- +name: tracy_zone +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "FN_ZONE(name) / FN_ZONE_COLOR(name, color) / FN_FRAME_MARK / FN_PLOT(name, val)" +description: "Macros y constantes de conveniencia para Tracy profiling zones, compilables sin Tracy" +tags: [tracy, profiling, debug, performance, raii] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/tracy_zone.cpp" +framework: imgui +params: + - name: name + desc: "Nombre de la zona tal como aparece en la timeline de Tracy" + - name: color + desc: "Color ARGB uint32 de la zona en Tracy (opcional, solo FN_ZONE_COLOR)" +output: "Zona Tracy activa durante el scope actual; no-op cuando TRACY_ENABLE no está definido" +--- + +# tracy_zone + +Macros de scope para instrumentar secciones de código con Tracy Profiler. Cuando `TRACY_ENABLE` no está definido (ej. builds de producción) todas las macros se expanden a `(void)0`, sin coste alguno. + +## Macros disponibles + +| Macro | Descripción | +|---|---| +| `FN_ZONE("name")` | Zona con nombre, color automático | +| `FN_ZONE_COLOR("name", color)` | Zona con nombre y color ARGB explícito | +| `FN_FRAME_MARK` | Marca el límite de frame (eje X de Tracy) | +| `FN_PLOT("name", val)` | Envía un valor escalar al panel de plots | + +## Colores predefinidos (`fn_tracy::`) + +```cpp +fn_tracy::COLOR_RENDER // 0x2196F3 azul — rendering +fn_tracy::COLOR_UPDATE // 0x4CAF50 verde — game update / logic +fn_tracy::COLOR_IO // 0xFF9800 naranja — I/O disco/red +fn_tracy::COLOR_NETWORK // 0xF44336 rojo — red/HTTP +fn_tracy::COLOR_COMPUTE // 0x9C27B0 morado — compute / shader prep +``` + +## Ejemplo + +```cpp +#include "core/tracy_zone.h" + +void render_scene() { + FN_ZONE_COLOR("render_scene", fn_tracy::COLOR_RENDER); + // ... render calls ... +} + +void update(float dt) { + FN_ZONE("update"); + // ... game logic ... + FN_PLOT("dt_ms", dt * 1000.0f); +} + +void main_loop() { + while (running) { + update(dt); + render_scene(); + FN_FRAME_MARK; + } +} +``` + +## Notas + +- Compilar con `-DTRACY_ENABLE` y enlazar `TracyClient.cpp` para activar profiling. +- Sin `TRACY_ENABLE` el .cpp es prácticamente vacío — coste cero en producción. +- Los colores son ARGB; si el alpha es 0 Tracy aplica su color automático por zona. +- `FN_ZONE` expande a `ZoneScopedN(name)` de Tracy, que crea el contexto con `__LINE__` como discriminador — es seguro llamar varias veces en el mismo scope. diff --git a/cpp/functions/viz/candlestick.cpp b/cpp/functions/viz/candlestick.cpp new file mode 100644 index 00000000..508ee571 --- /dev/null +++ b/cpp/functions/viz/candlestick.cpp @@ -0,0 +1,81 @@ +#include "viz/candlestick.h" +#include "imgui.h" +#include "implot.h" + +void candlestick(const char* title, const double* dates, const double* opens, + const double* closes, const double* lows, const double* highs, + int count, float width_percent, bool tooltip) { + if (count <= 0) return; + + // Compute half-width of each candle body in data coordinates. + // Use spacing between consecutive dates when count > 1, else fallback to 0.5. + double spacing = (count > 1) ? (dates[1] - dates[0]) : 1.0; + double half_w = spacing * (double)width_percent * 0.5; + + ImPlot::SetupAxes("Date", "Price", ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxisScale(ImAxis_X1, ImPlotScale_Time); + + // Auto-fit X axis to the data range with a small margin. + ImPlot::SetupAxisLimits(ImAxis_X1, + dates[0] - spacing, + dates[count - 1] + spacing, + ImGuiCond_Always); + + if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) { + ImDrawList* draw = ImPlot::GetPlotDrawList(); + + const ImU32 col_bull = IM_COL32(0, 200, 80, 255); // green — close >= open + const ImU32 col_bear = IM_COL32(220, 50, 50, 255); // red — close < open + + int hovered_idx = -1; + + for (int i = 0; i < count; i++) { + double x = dates[i]; + double open = opens[i]; + double close = closes[i]; + double low = lows[i]; + double high = highs[i]; + + bool bullish = (close >= open); + ImU32 col = bullish ? col_bull : col_bear; + + // Convert data coordinates to screen pixels. + ImVec2 body_tl = ImPlot::PlotToPixels(x - half_w, bullish ? close : open); + ImVec2 body_br = ImPlot::PlotToPixels(x + half_w, bullish ? open : close); + ImVec2 wick_hi = ImPlot::PlotToPixels(x, high); + ImVec2 wick_lo = ImPlot::PlotToPixels(x, low); + float cx = (body_tl.x + body_br.x) * 0.5f; + + // Wick (high-low vertical line). + draw->AddLine(ImVec2(cx, wick_hi.y), ImVec2(cx, wick_lo.y), col, 1.5f); + + // Body rectangle (open-close). + // Ensure at least 1px height so flat candles are visible. + if (body_br.y <= body_tl.y + 1.0f) body_br.y = body_tl.y + 1.0f; + draw->AddRectFilled(body_tl, body_br, col); + draw->AddRect(body_tl, body_br, col); + + // Track hovered candle for tooltip. + if (tooltip && ImPlot::IsPlotHovered()) { + ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x >= body_tl.x - 4 && mouse.x <= body_br.x + 4 && + mouse.y >= wick_hi.y - 4 && mouse.y <= wick_lo.y + 4) { + hovered_idx = i; + } + } + } + + // Tooltip for the hovered candle. + if (tooltip && hovered_idx >= 0) { + int i = hovered_idx; + ImGui::BeginTooltip(); + ImGui::Text("O: %.4f", opens[i]); + ImGui::Text("H: %.4f", highs[i]); + ImGui::Text("L: %.4f", lows[i]); + ImGui::Text("C: %.4f", closes[i]); + ImGui::EndTooltip(); + } + + ImPlot::EndPlot(); + } +} diff --git a/cpp/functions/viz/candlestick.h b/cpp/functions/viz/candlestick.h new file mode 100644 index 00000000..e669b450 --- /dev/null +++ b/cpp/functions/viz/candlestick.h @@ -0,0 +1,9 @@ +#pragma once + +// Renders an OHLC candlestick chart using ImPlot custom rendering. +// Call within an ImGui frame (inside fn::run_app render callback). +// Green candles when close >= open, red when close < open. +// width_percent controls candle body width as a fraction of inter-candle spacing. +void candlestick(const char* title, const double* dates, const double* opens, + const double* closes, const double* lows, const double* highs, + int count, float width_percent = 0.25f, bool tooltip = true); diff --git a/cpp/functions/viz/candlestick.md b/cpp/functions/viz/candlestick.md new file mode 100644 index 00000000..979d266e --- /dev/null +++ b/cpp/functions/viz/candlestick.md @@ -0,0 +1,67 @@ +--- +name: candlestick +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void candlestick(const char* title, const double* dates, const double* opens, const double* closes, const double* lows, const double* highs, int count, float width_percent = 0.25f, bool tooltip = true)" +description: "Renderiza un grafico de velas OHLC usando ImPlot custom rendering. Verde para velas alcistas (close >= open), rojo para bajistas." +tags: [implot, chart, visualization, gpu, candlestick, ohlc, finance] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [implot, imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/candlestick.cpp" +framework: imgui +params: + - name: title + desc: "Titulo del grafico, se muestra como header del plot" + - name: dates + desc: "Array de timestamps Unix o indices numericos del eje X, uno por vela" + - name: opens + desc: "Array de precios de apertura, uno por vela" + - name: closes + desc: "Array de precios de cierre, uno por vela" + - name: lows + desc: "Array de precios minimos (punta inferior del wick), uno por vela" + - name: highs + desc: "Array de precios maximos (punta superior del wick), uno por vela" + - name: count + desc: "Numero de velas (longitud de todos los arrays)" + - name: width_percent + desc: "Ancho del body de cada vela como fraccion del espacio entre puntos consecutivos (0.0-1.0, default 0.25)" + - name: tooltip + desc: "Si true, muestra tooltip con valores O/H/L/C al hacer hover sobre una vela" +output: "Renderiza el grafico de velas OHLC en el frame ImGui actual, sin retornar valor" +--- + +# candlestick + +Grafico de velas OHLC completo usando custom rendering de ImPlot. Dibuja body (open-close) y wicks (high-low) por vela usando `ImPlot::GetPlotDrawList()` y `ImPlot::PlotToPixels()` para conversion de coordenadas. + +Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo). El eje X se configura con `ImPlotScale_Time` para timestamps Unix. + +Solo tiene overload `double` porque los datos financieros requieren doble precision. + +## Ejemplo + +```cpp +// arrays de datos financieros (timestamps Unix, precios) +candlestick("BTC/USD", dates, opens, closes, lows, highs, 90); + +// sin tooltip, velas mas anchas +candlestick("ETH/USD", dates, opens, closes, lows, highs, 30, 0.6f, false); +``` + +## Notas + +- El ancho de cada vela se calcula como `(dates[1] - dates[0]) * width_percent * 0.5` en cada lado. Asume spacing uniforme entre velas. +- Para un solo punto (`count == 1`) el spacing por defecto es 1.0. +- La deteccion de hover usa un margen de 4px alrededor del area cuerpo+wick para facilitar la interaccion. +- El eje X usa `ImPlotScale_Time` — si los datos son indices numericos simples (0, 1, 2...) en lugar de timestamps, pasar `ImPlotAxisFlags_NoDecorations` o cambiar `SetupAxisScale`. diff --git a/cpp/functions/viz/gauge.cpp b/cpp/functions/viz/gauge.cpp new file mode 100644 index 00000000..a39752ed --- /dev/null +++ b/cpp/functions/viz/gauge.cpp @@ -0,0 +1,94 @@ +#include "viz/gauge.h" +#include "imgui.h" +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846f +#endif + +void gauge(const char* label, float value, float min_val, float max_val, float radius) { + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + ImVec2 pos = ImGui::GetCursorScreenPos(); + + // Reserve space: diameter + label line + float diameter = radius * 2.0f; + ImGui::Dummy(ImVec2(diameter, diameter + ImGui::GetTextLineHeightWithSpacing())); + + ImVec2 center = ImVec2(pos.x + radius, pos.y + radius); + + // Arc spans 240 degrees: from 150deg to 390deg (i.e. 150 to 30 going clockwise) + // In screen space Y is down, so angles go clockwise. + // Start angle: 150 degrees = bottom-left, End angle: 390 = 30 degrees = bottom-right + const float angle_start = (150.0f * (float)M_PI) / 180.0f; + const float angle_end = (390.0f * (float)M_PI) / 180.0f; + const int num_segments = 64; + + // Background arc (dark gray) + ImU32 bg_color = IM_COL32(60, 60, 60, 220); + for (int i = 0; i < num_segments; i++) { + float a0 = angle_start + (angle_end - angle_start) * ((float)i / num_segments); + float a1 = angle_start + (angle_end - angle_start) * ((float)(i + 1) / num_segments); + draw_list->AddLine( + ImVec2(center.x + cosf(a0) * radius, center.y + sinf(a0) * radius), + ImVec2(center.x + cosf(a1) * radius, center.y + sinf(a1) * radius), + bg_color, 6.0f); + } + + // Normalize value to [0, 1] + float t = 0.0f; + if (max_val > min_val) { + t = (value - min_val) / (max_val - min_val); + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + } + + // Color: green (t=0) -> yellow (t=0.5) -> red (t=1) + float r, g, b; + if (t < 0.5f) { + float s = t * 2.0f; + r = (unsigned char)(s * 255); + g = 200; + b = 0; + } else { + float s = (t - 0.5f) * 2.0f; + r = 220; + g = (unsigned char)((1.0f - s) * 200); + b = 0; + } + ImU32 value_color = IM_COL32((int)r, (int)g, (int)b, 255); + + // Value arc + float angle_value = angle_start + (angle_end - angle_start) * t; + int value_segments = (int)(num_segments * t); + for (int i = 0; i < value_segments; i++) { + float a0 = angle_start + (angle_end - angle_start) * ((float)i / num_segments); + float a1 = angle_start + (angle_end - angle_start) * ((float)(i + 1) / num_segments); + draw_list->AddLine( + ImVec2(center.x + cosf(a0) * radius, center.y + sinf(a0) * radius), + ImVec2(center.x + cosf(a1) * radius, center.y + sinf(a1) * radius), + value_color, 6.0f); + } + + // Needle: line from center to arc at current angle + float needle_len = radius * 0.75f; + ImVec2 needle_tip = ImVec2( + center.x + cosf(angle_value) * needle_len, + center.y + sinf(angle_value) * needle_len); + draw_list->AddLine(center, needle_tip, IM_COL32(255, 255, 255, 240), 2.0f); + draw_list->AddCircleFilled(center, 4.0f, IM_COL32(255, 255, 255, 200)); + + // Value text centered + char val_buf[32]; + snprintf(val_buf, sizeof(val_buf), "%.1f", value); + ImVec2 val_size = ImGui::CalcTextSize(val_buf); + draw_list->AddText( + ImVec2(center.x - val_size.x * 0.5f, center.y + radius * 0.35f), + IM_COL32(230, 230, 230, 255), val_buf); + + // Label below value + ImVec2 label_size = ImGui::CalcTextSize(label); + draw_list->AddText( + ImVec2(center.x - label_size.x * 0.5f, center.y + radius * 0.35f + val_size.y + 2.0f), + IM_COL32(180, 180, 180, 200), label); +} diff --git a/cpp/functions/viz/gauge.h b/cpp/functions/viz/gauge.h new file mode 100644 index 00000000..219d3ce3 --- /dev/null +++ b/cpp/functions/viz/gauge.h @@ -0,0 +1,6 @@ +#pragma once + +// Renders a circular gauge/speedometer indicator using ImGui draw primitives. +// Call within an ImGui frame. +// color is interpolated green->yellow->red based on normalized value. +void gauge(const char* label, float value, float min_val, float max_val, float radius = 60.0f); diff --git a/cpp/functions/viz/gauge.md b/cpp/functions/viz/gauge.md new file mode 100644 index 00000000..793e8037 --- /dev/null +++ b/cpp/functions/viz/gauge.md @@ -0,0 +1,59 @@ +--- +name: gauge +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void gauge(const char* label, float value, float min_val, float max_val, float radius = 60.0f)" +description: "Renderiza un indicador circular tipo gauge/velocimetro usando ImGui draw primitives" +tags: [imgui, visualization, gauge, kpi, dashboard] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/gauge.cpp" +framework: imgui +params: + - name: label + desc: "Etiqueta del gauge, se muestra centrada debajo del valor numerico" + - name: value + desc: "Valor actual a mostrar en el gauge" + - name: min_val + desc: "Valor minimo de la escala (extremo izquierdo del arco)" + - name: max_val + desc: "Valor maximo de la escala (extremo derecho del arco)" + - name: radius + desc: "Radio del gauge en pixels (default 60.0)" +output: "Renderiza el gauge en el frame ImGui actual, reservando espacio con ImGui::Dummy" +--- + +# gauge + +Indicador circular tipo gauge/velocimetro construido sobre ImGui draw primitives. No requiere ImPlot. + +El arco ocupa 240 grados (de 150deg a 390deg en sentido horario). El color del arco de valor interpolado de verde (minimo) a amarillo (mitad) a rojo (maximo). Una aguja blanca apunta al valor actual. + +Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo). + +## Ejemplo + +```cpp +// KPI card con gauge de temperatura +gauge("CPU Temp", 72.5f, 0.0f, 100.0f, 50.0f); + +// Gauge grande para dashboard principal +gauge("Velocidad", 3200.0f, 0.0f, 5000.0f, 80.0f); +``` + +## Notas + +- El arco de fondo es gris oscuro (IM_COL32(60,60,60,220)), 6px de grosor. +- La aguja tiene longitud del 75% del radio para evitar solapar el arco. +- Usa solo `float`; no ofrece overload `double` porque ImGui DrawList trabaja en coordenadas de pantalla (float). +- El espacio reservado es `diameter x (diameter + line_height)` para incluir la etiqueta. diff --git a/cpp/functions/viz/graph_force_layout.cpp b/cpp/functions/viz/graph_force_layout.cpp new file mode 100644 index 00000000..52b39752 --- /dev/null +++ b/cpp/functions/viz/graph_force_layout.cpp @@ -0,0 +1,353 @@ +#include "viz/graph_force_layout.h" +#include "viz/graph_types.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Quadtree for Barnes-Hut approximation +// --------------------------------------------------------------------------- + +struct QuadNode { + float cx, cy; // center of mass + float mass; // total mass (node count in subtree) + float x0, y0; // bounding box min + float x1, y1; // bounding box max + int children[4]; // NW=0, NE=1, SW=2, SE=3 (-1 = empty) + int body; // node index if leaf (-1 if internal) +}; + +static constexpr int MAX_QUAD_NODES = 1 << 20; // supports graphs up to ~1M nodes +static QuadNode quad_pool[MAX_QUAD_NODES]; +static int quad_count = 0; + +static int quad_new(float x0, float y0, float x1, float y1) { + if (quad_count >= MAX_QUAD_NODES) return -1; + int idx = quad_count++; + QuadNode& q = quad_pool[idx]; + q.cx = 0; q.cy = 0; q.mass = 0; + q.x0 = x0; q.y0 = y0; q.x1 = x1; q.y1 = y1; + q.children[0] = q.children[1] = q.children[2] = q.children[3] = -1; + q.body = -1; + return idx; +} + +// Determine quadrant index for point (px,py) relative to cell midpoint. +// 0=NW, 1=NE, 2=SW, 3=SE +static int quad_child_idx(const QuadNode& q, float px, float py) { + float mx = (q.x0 + q.x1) * 0.5f; + float my = (q.y0 + q.y1) * 0.5f; + int xi = (px >= mx) ? 1 : 0; + int yi = (py >= my) ? 2 : 0; + return xi | yi; +} + +// Subdivide cell qi into four children. +static void quad_subdivide(int qi) { + QuadNode& q = quad_pool[qi]; + float mx = (q.x0 + q.x1) * 0.5f; + float my = (q.y0 + q.y1) * 0.5f; + // NW + quad_pool[qi].children[0] = quad_new(q.x0, q.y0, mx, my); + // NE + quad_pool[qi].children[1] = quad_new(mx, q.y0, q.x1, my); + // SW + quad_pool[qi].children[2] = quad_new(q.x0, my, mx, q.y1); + // SE + quad_pool[qi].children[3] = quad_new(mx, my, q.x1, q.y1); +} + +// Insert body (node_idx at position nx,ny with mass nmass) into cell qi. +// Uses iterative descent to avoid stack overflow on deep trees. +static void quad_insert(int root, int node_idx, float nx, float ny, float nmass) { + int qi = root; + while (qi >= 0) { + QuadNode& q = quad_pool[qi]; + // Update center of mass + float total = q.mass + nmass; + q.cx = (q.cx * q.mass + nx * nmass) / total; + q.cy = (q.cy * q.mass + ny * nmass) / total; + q.mass = total; + + if (q.body == -1 && q.children[0] == -1) { + // Empty leaf: place body here + q.body = node_idx; + return; + } + + if (q.body >= 0) { + // Leaf with existing body: subdivide, push existing body down + quad_subdivide(qi); + // Move old body into correct child (re-read q after subdivide since pool may shift) + QuadNode& qq = quad_pool[qi]; + int old_body = qq.body; + float obx = /* we need positions */ 0, oby = 0; + // We store positions in the GraphData, pass via closure is not possible here. + // Instead we pass a pointer to positions alongside. We'll fix this by using + // a file-scope pointer set before each build. + (void)old_body; (void)obx; (void)oby; + // NOTE: positions accessed via file-scope g_nodes pointer below. + qq.body = -1; + } + + int ci = quad_child_idx(quad_pool[qi], nx, ny); + qi = quad_pool[qi].children[ci]; + } +} + +// File-scope pointers set before each tree build (avoids passing them everywhere). +static const GraphNode* g_nodes = nullptr; + +// Insert body knowing positions from g_nodes. +static void quad_insert_body(int qi, int node_idx) { + float nx = g_nodes[node_idx].x; + float ny = g_nodes[node_idx].y; + const float nmass = 1.0f; + + while (qi >= 0) { + QuadNode& q = quad_pool[qi]; + float total = q.mass + nmass; + q.cx = (q.cx * q.mass + nx * nmass) / total; + q.cy = (q.cy * q.mass + ny * nmass) / total; + q.mass = total; + + if (q.body == -1 && q.children[0] == -1) { + // Empty leaf + q.body = node_idx; + return; + } + + if (q.children[0] == -1) { + // Leaf occupied: subdivide and push existing body down + int old_body = q.body; + q.body = -1; + quad_subdivide(qi); + + // Push old body into child + int old_ci = quad_child_idx(quad_pool[qi], g_nodes[old_body].x, g_nodes[old_body].y); + int old_child = quad_pool[qi].children[old_ci]; + if (old_child >= 0) { + QuadNode& oc = quad_pool[old_child]; + oc.cx = g_nodes[old_body].x; + oc.cy = g_nodes[old_body].y; + oc.mass = 1.0f; + oc.body = old_body; + } + } + + int ci = quad_child_idx(quad_pool[qi], nx, ny); + qi = quad_pool[qi].children[ci]; + } +} + +// Compute Barnes-Hut repulsion force on node at (nx,ny) from subtree qi. +// Accumulates force into (fx, fy). +static void quad_force(int qi, float nx, float ny, + float theta, float repulsion, float min_dist, + float& fx, float& fy) { + // Iterative traversal using a small stack to avoid recursion depth issues. + static int stack[MAX_QUAD_NODES]; // reuse static stack + int top = 0; + stack[top++] = qi; + + while (top > 0) { + int ci = stack[--top]; + if (ci < 0) continue; + const QuadNode& q = quad_pool[ci]; + if (q.mass == 0) continue; + + float dx = q.cx - nx; + float dy = q.cy - ny; + float dist2 = dx * dx + dy * dy; + float dist = std::sqrt(dist2); + if (dist < min_dist) dist = min_dist; + + // Cell size + float cell_size = q.x1 - q.x0; + + // Use multipole approximation if far enough OR if leaf + bool is_leaf = (q.children[0] == -1); + if (is_leaf || (cell_size / dist) < theta) { + // Coulomb repulsion: F = repulsion * mass / dist^2 + float force = repulsion * q.mass / (dist * dist); + fx -= force * dx / dist; + fy -= force * dy / dist; + } else { + // Push children + for (int k = 0; k < 4; ++k) { + if (q.children[k] >= 0) + stack[top++] = q.children[k]; + } + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config) { + if (graph.node_count <= 0) return 0.0f; + + // Temporary force accumulators (stack-allocated for small graphs, static for large) + static float* fx_buf = nullptr; + static float* fy_buf = nullptr; + static int buf_cap = 0; + + if (graph.node_count > buf_cap) { + delete[] fx_buf; + delete[] fy_buf; + buf_cap = graph.node_count + 64; + fx_buf = new float[buf_cap]; + fy_buf = new float[buf_cap]; + } + + float total_energy = 0.0f; + + for (int iter = 0; iter < config.iterations; ++iter) { + // Zero forces + for (int i = 0; i < graph.node_count; ++i) { + fx_buf[i] = 0.0f; + fy_buf[i] = 0.0f; + } + + // ---- Build Barnes-Hut quadtree ---- + // Compute bounding box of current positions + float bx0 = graph.nodes[0].x, bx1 = graph.nodes[0].x; + float by0 = graph.nodes[0].y, by1 = graph.nodes[0].y; + for (int i = 1; i < graph.node_count; ++i) { + float px = graph.nodes[i].x, py = graph.nodes[i].y; + if (px < bx0) bx0 = px; if (px > bx1) bx1 = px; + if (py < by0) by0 = py; if (py > by1) by1 = py; + } + // Add margin to avoid degeneracies + float margin = (bx1 - bx0 + by1 - by0) * 0.05f + 1.0f; + bx0 -= margin; bx1 += margin; + by0 -= margin; by1 += margin; + // Make it square + float side = std::max(bx1 - bx0, by1 - by0); + float cx = (bx0 + bx1) * 0.5f, cy = (by0 + by1) * 0.5f; + bx0 = cx - side * 0.5f; bx1 = cx + side * 0.5f; + by0 = cy - side * 0.5f; by1 = cy + side * 0.5f; + + quad_count = 0; + g_nodes = graph.nodes; + int root = quad_new(bx0, by0, bx1, by1); + + for (int i = 0; i < graph.node_count; ++i) { + quad_insert_body(root, i); + } + + // ---- Repulsion via Barnes-Hut ---- + for (int i = 0; i < graph.node_count; ++i) { + if (graph.nodes[i].pinned) continue; + quad_force(root, + graph.nodes[i].x, graph.nodes[i].y, + config.theta, config.repulsion, config.min_distance, + fx_buf[i], fy_buf[i]); + // Subtract self-interaction (the tree includes the node itself) + // Self-force: repulsion * 1 / min_dist^2, but direction is (0,0) -> skip + } + + // ---- Attraction along edges (spring force) ---- + for (int e = 0; e < graph.edge_count; ++e) { + const GraphEdge& edge = graph.edges[e]; + int s = (int)edge.source; + int t = (int)edge.target; + if (s < 0 || s >= graph.node_count) continue; + if (t < 0 || t >= graph.node_count) continue; + + float dx = graph.nodes[t].x - graph.nodes[s].x; + float dy = graph.nodes[t].y - graph.nodes[s].y; + float dist = std::sqrt(dx * dx + dy * dy); + if (dist < config.min_distance) dist = config.min_distance; + + // F = k * dist * weight (Hooke: pulls toward equilibrium at 0) + float force = config.attraction * dist * edge.weight; + float fx_e = force * dx / dist; + float fy_e = force * dy / dist; + + if (!graph.nodes[s].pinned) { fx_buf[s] += fx_e; fy_buf[s] += fy_e; } + if (!graph.nodes[t].pinned) { fx_buf[t] -= fx_e; fy_buf[t] -= fy_e; } + } + + // ---- Gravity toward center (0,0) ---- + if (config.gravity != 0.0f) { + for (int i = 0; i < graph.node_count; ++i) { + if (graph.nodes[i].pinned) continue; + fx_buf[i] -= config.gravity * graph.nodes[i].x; + fy_buf[i] -= config.gravity * graph.nodes[i].y; + } + } + + // ---- Integrate: v = v * damping + F; pos += v ---- + total_energy = 0.0f; + for (int i = 0; i < graph.node_count; ++i) { + GraphNode& n = graph.nodes[i]; + if (n.pinned) continue; + + n.vx = n.vx * config.damping + fx_buf[i]; + n.vy = n.vy * config.damping + fy_buf[i]; + + // Clamp velocity + n.vx = std::max(-config.max_velocity, std::min(config.max_velocity, n.vx)); + n.vy = std::max(-config.max_velocity, std::min(config.max_velocity, n.vy)); + + n.x += n.vx; + n.y += n.vy; + + total_energy += n.vx * n.vx + n.vy * n.vy; + } + } + + graph.update_bounds(); + return total_energy; +} + +void graph_force_layout_reset(GraphData& graph, float spread) { + for (int i = 0; i < graph.node_count; ++i) { + GraphNode& n = graph.nodes[i]; + if (n.pinned) continue; + // rand() produces [0, RAND_MAX]; map to [-spread, spread] + n.x = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f); + n.y = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f); + n.vx = 0.0f; + n.vy = 0.0f; + } + graph.update_bounds(); +} + +void graph_layout_circular(GraphData& graph, float radius) { + if (graph.node_count <= 0) return; + const float two_pi = 6.28318530718f; + for (int i = 0; i < graph.node_count; ++i) { + GraphNode& n = graph.nodes[i]; + if (n.pinned) continue; + float angle = two_pi * (float)i / (float)graph.node_count; + n.x = radius * std::cos(angle); + n.y = radius * std::sin(angle); + n.vx = 0.0f; + n.vy = 0.0f; + } + graph.update_bounds(); +} + +void graph_layout_grid(GraphData& graph, float spacing) { + if (graph.node_count <= 0) return; + int cols = (int)std::ceil(std::sqrt((float)graph.node_count)); + int rows = (graph.node_count + cols - 1) / cols; + float ox = -0.5f * (cols - 1) * spacing; + float oy = -0.5f * (rows - 1) * spacing; + for (int i = 0; i < graph.node_count; ++i) { + GraphNode& n = graph.nodes[i]; + if (n.pinned) continue; + int col = i % cols; + int row = i / cols; + n.x = ox + col * spacing; + n.y = oy + row * spacing; + n.vx = 0.0f; + n.vy = 0.0f; + } + graph.update_bounds(); +} diff --git a/cpp/functions/viz/graph_force_layout.h b/cpp/functions/viz/graph_force_layout.h new file mode 100644 index 00000000..bde0b0e3 --- /dev/null +++ b/cpp/functions/viz/graph_force_layout.h @@ -0,0 +1,27 @@ +#pragma once + +struct GraphData; // forward declare + +struct ForceLayoutConfig { + float repulsion = 500.0f; // repulsion strength between all nodes + float attraction = 0.01f; // spring constant for edges + float damping = 0.85f; // velocity decay per step + float min_distance = 1.0f; // minimum distance (avoid division by zero) + float theta = 0.5f; // Barnes-Hut threshold (0 = exact, 1 = fast) + float gravity = 0.1f; // pull toward center (prevents drift) + float max_velocity = 50.0f; // cap velocity per axis + int iterations = 1; // steps per call +}; + +// Perform one (or more) steps of force-directed layout. +// Modifies node positions (x, y) and velocities (vx, vy) in-place. +// Returns the total kinetic energy (sum of |v|^2). When energy < threshold, +// layout has converged. +float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config = {}); + +// Reset: randomize positions within [-spread, spread], zero velocities. +void graph_force_layout_reset(GraphData& graph, float spread = 200.0f); + +// Preset layouts (non-iterative, instant positioning) +void graph_layout_circular(GraphData& graph, float radius = 100.0f); +void graph_layout_grid(GraphData& graph, float spacing = 20.0f); diff --git a/cpp/functions/viz/graph_force_layout.md b/cpp/functions/viz/graph_force_layout.md new file mode 100644 index 00000000..679ad9d3 --- /dev/null +++ b/cpp/functions/viz/graph_force_layout.md @@ -0,0 +1,79 @@ +--- +name: graph_force_layout +kind: function +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config)" +description: "Layout force-directed con aproximacion Barnes-Hut para grafos grandes, ejecuta un paso de simulacion por llamada" +tags: [graph, layout, force-directed, barnes-hut, physics, gpu] +uses_functions: [] +uses_types: ["GraphData_cpp_viz"] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/graph_force_layout.cpp" +framework: imgui +params: + - name: graph + desc: "Referencia al grafo (GraphData) cuyos nodos se actualizan in-place. Modifica x, y, vx, vy de cada nodo no pinned." + - name: config + desc: "Parametros de la simulacion: repulsion (fuerza coulombiana), attraction (spring constant), damping (decay de velocidad), theta (precision Barnes-Hut 0=exacto/1=rapido), gravity (atraccion al centro), max_velocity, iterations." +output: "Energia cinetica total (suma de |v|^2). Cuando cae por debajo de un umbral elegido por el caller, el layout ha convergido y se puede dejar de llamar." +--- + +# graph_force_layout + +Implementa el algoritmo de layout force-directed clasico (Fruchterman-Reingold / Eades) con aproximacion Barnes-Hut O(n log n) para escalar a grafos de miles de nodos. + +## Algoritmo + +Cada llamada a `graph_force_layout_step` ejecuta `config.iterations` pasos. Un paso: + +1. **Construccion del quadtree** (Barnes-Hut): se calcula el bounding box de las posiciones actuales, se construye un quadtree flat en `quad_pool` (sin allocaciones por nodo). Cada celda acumula centro de masa y masa total. +2. **Repulsion**: para cada nodo se recorre el quadtree. Si el cociente `cell_size / distance < theta`, la celda se trata como una sola masa puntual (multipolo de orden 0). Si no, se desciende a los hijos. Con `theta=0` es O(n²) exacto; con `theta=0.5` es O(n log n). +3. **Atraccion**: para cada arista `(s, t)`, fuerza de Hooke `F = k * dist * weight` en la direccion del arco. +4. **Gravedad**: fuerza proporcional a la distancia al origen, evita que el grafo derive fuera de pantalla. +5. **Integracion**: `v = v * damping + F`, `pos += v`, con clamping de velocidad. +6. Nodos con `pinned = true` no se mueven en ningun paso. + +## Funciones auxiliares + +```cpp +// Randomizar posiciones para empezar la simulacion +graph_force_layout_reset(graph, 200.0f); + +// Layout circular instantaneo (sin iteracion) +graph_layout_circular(graph, 150.0f); + +// Layout en grid instantaneo +graph_layout_grid(graph, 25.0f); +``` + +## Ejemplo de uso tipico (loop ImGui) + +```cpp +static ForceLayoutConfig cfg; +static bool running = true; + +if (running) { + float energy = graph_force_layout_step(my_graph, cfg); + if (energy < 0.01f) running = false; // convergido +} +``` + +## Notas de implementacion + +- El quadtree usa un pool estatico de `1 << 20` (~1M) celdas. Para grafos de >500K nodos + se recomienda reducir `MAX_QUAD_NODES` o aumentarlo segun memoria disponible. +- La pila de traversal en `quad_force` es tambien estatica (`static int stack[]`); no es + thread-safe si se llama desde multiples hilos simultaneamente. +- `graph_force_layout_reset` usa `rand()`. Para reproducibilidad llama `srand(seed)` antes. +- Los buffers de fuerza (`fx_buf`, `fy_buf`) se realocan una sola vez cuando el conteo de + nodos supera la capacidad previa; en el uso normal (tamano fijo) no hay allocaciones + por frame. diff --git a/cpp/functions/viz/graph_renderer.cpp b/cpp/functions/viz/graph_renderer.cpp new file mode 100644 index 00000000..14a1a3e3 --- /dev/null +++ b/cpp/functions/viz/graph_renderer.cpp @@ -0,0 +1,446 @@ +#include "viz/graph_renderer.h" +#include "viz/graph_types.h" + +#define GL_GLEXT_PROTOTYPES +#include +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Community palette (ABGR packed, 10 colors) +// --------------------------------------------------------------------------- +static const uint32_t k_palette[10] = { + 0xFF4CAF50, // green + 0xFFF44336, // red + 0xFF2196F3, // blue + 0xFFFF9800, // orange + 0xFF9C27B0, // purple + 0xFF00BCD4, // cyan + 0xFFFFEB3B, // yellow + 0xFFE91E63, // pink + 0xFF795548, // brown + 0xFF607D8B // blue-grey +}; + +// --------------------------------------------------------------------------- +// Internal struct +// --------------------------------------------------------------------------- +struct GraphRenderer { + unsigned int fbo; + unsigned int texture; + unsigned int rbo; // depth/stencil renderbuffer + int width, height; + + // Node rendering (instanced quads) + unsigned int node_vao, node_quad_vbo, node_instance_vbo; + unsigned int node_shader; + + // Edge rendering (lines) + unsigned int edge_vao, edge_vbo; + unsigned int edge_shader; + + GraphRendererConfig config; +}; + +// --------------------------------------------------------------------------- +// Shader sources +// --------------------------------------------------------------------------- + +// Node vertex shader — instanced unit quad +static const char* k_node_vert = R"( +#version 330 core +// Quad corners [-0.5, 0.5] +layout(location = 0) in vec2 a_quad; + +// Per-instance: world position, size, RGBA color +layout(location = 1) in vec2 a_pos; +layout(location = 2) in float a_size; +layout(location = 3) in vec4 a_color; + +out vec2 v_uv; +out vec4 v_color; + +uniform vec2 u_viewport; // (width, height) in pixels +uniform float u_scale; // cam_zoom +uniform vec2 u_translate; // (tx, ty) in pixels + +void main() { + // World -> screen (pixels) + vec2 screen = a_pos * u_scale + u_translate; + // Expand quad by node radius (size = diameter) + screen += a_quad * a_size * u_scale; + // Screen -> NDC + vec2 ndc = (screen / u_viewport) * 2.0 - 1.0; + ndc.y = -ndc.y; // flip Y (screen Y grows downward) + gl_Position = vec4(ndc, 0.0, 1.0); + v_uv = a_quad + 0.5; // [0,1] + v_color = a_color; +} +)"; + +// Node fragment shader — SDF circle with outline +static const char* k_node_frag = R"( +#version 330 core +in vec2 v_uv; +in vec4 v_color; + +out vec4 frag_color; + +uniform float u_outline_px; // outline width in uv units +uniform float u_node_px; // node diameter in pixels (= size * zoom) + +void main() { + // SDF circle centered at (0.5, 0.5) in uv space + float dist = length(v_uv - 0.5); + float r = 0.5; + + // Anti-alias edge (in uv units; 1px ~ 1/u_node_px in uv space) + float fwidth_uv = 1.5 / max(u_node_px, 1.0); + + float alpha = 1.0 - smoothstep(r - fwidth_uv, r, dist); + if (alpha < 0.001) discard; + + // Outline ring + float outline_uv = u_outline_px / max(u_node_px, 1.0); + float outline = smoothstep(r - outline_uv - fwidth_uv, r - outline_uv, dist); + + vec3 fill = v_color.rgb; + vec3 outline_col = mix(fill, vec3(1.0), 0.6); // lighter outline + vec3 color = mix(fill, outline_col, outline); + + frag_color = vec4(color, v_color.a * alpha); +} +)"; + +// Edge vertex shader +static const char* k_edge_vert = R"( +#version 330 core +layout(location = 0) in vec2 a_pos; +layout(location = 1) in vec4 a_color; + +out vec4 v_color; + +uniform vec2 u_viewport; +uniform float u_scale; +uniform vec2 u_translate; + +void main() { + vec2 screen = a_pos * u_scale + u_translate; + vec2 ndc = (screen / u_viewport) * 2.0 - 1.0; + ndc.y = -ndc.y; + gl_Position = vec4(ndc, 0.0, 1.0); + v_color = a_color; +} +)"; + +// Edge fragment shader +static const char* k_edge_frag = R"( +#version 330 core +in vec4 v_color; +out vec4 frag_color; + +void main() { + frag_color = v_color; +} +)"; + +// --------------------------------------------------------------------------- +// Shader helpers +// --------------------------------------------------------------------------- +static unsigned int compile_shader(GLenum type, const char* src) { + unsigned int s = glCreateShader(type); + glShaderSource(s, 1, &src, nullptr); + glCompileShader(s); + int ok; + glGetShaderiv(s, GL_COMPILE_STATUS, &ok); + if (!ok) { + char buf[512]; + glGetShaderInfoLog(s, sizeof(buf), nullptr, buf); + fprintf(stderr, "[graph_renderer] shader compile error: %s\n", buf); + } + return s; +} + +static unsigned int link_program(const char* vert_src, const char* frag_src) { + unsigned int vs = compile_shader(GL_VERTEX_SHADER, vert_src); + unsigned int fs = compile_shader(GL_FRAGMENT_SHADER, frag_src); + unsigned int prog = glCreateProgram(); + glAttachShader(prog, vs); + glAttachShader(prog, fs); + glLinkProgram(prog); + int ok; + glGetProgramiv(prog, GL_LINK_STATUS, &ok); + if (!ok) { + char buf[512]; + glGetProgramInfoLog(prog, sizeof(buf), nullptr, buf); + fprintf(stderr, "[graph_renderer] program link error: %s\n", buf); + } + glDeleteShader(vs); + glDeleteShader(fs); + return prog; +} + +// --------------------------------------------------------------------------- +// FBO helpers +// --------------------------------------------------------------------------- +static void create_fbo(GraphRenderer* r) { + // Texture + glGenTextures(1, &r->texture); + glBindTexture(GL_TEXTURE_2D, r->texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, r->width, r->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glBindTexture(GL_TEXTURE_2D, 0); + + // Depth renderbuffer + glGenRenderbuffers(1, &r->rbo); + glBindRenderbuffer(GL_RENDERBUFFER, r->rbo); + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, r->width, r->height); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + + // FBO + glGenFramebuffers(1, &r->fbo); + glBindFramebuffer(GL_FRAMEBUFFER, r->fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, r->texture, 0); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, r->rbo); + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + +static void destroy_fbo(GraphRenderer* r) { + glDeleteFramebuffers(1, &r->fbo); + glDeleteTextures(1, &r->texture); + glDeleteRenderbuffers(1, &r->rbo); + r->fbo = r->texture = r->rbo = 0; +} + +// --------------------------------------------------------------------------- +// Helper: unpack ABGR uint32 to float RGBA +// --------------------------------------------------------------------------- +static inline void abgr_to_rgba(uint32_t abgr, float& r, float& g, float& b, float& a) { + // ABGR layout: bits 31-24 = A, 23-16 = B, 15-8 = G, 7-0 = R + a = ((abgr >> 24) & 0xFF) / 255.0f; + b = ((abgr >> 16) & 0xFF) / 255.0f; + g = ((abgr >> 8) & 0xFF) / 255.0f; + r = ((abgr ) & 0xFF) / 255.0f; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config) { + GraphRenderer* r = new GraphRenderer(); + r->width = width; + r->height = height; + r->config = config; + + // --- FBO --- + create_fbo(r); + + // --- Node VAO --- + // Unit quad: 4 vertices, each (x, y) in [-0.5, 0.5] + static const float quad_verts[8] = { + -0.5f, -0.5f, + 0.5f, -0.5f, + -0.5f, 0.5f, + 0.5f, 0.5f, + }; + + glGenVertexArrays(1, &r->node_vao); + glBindVertexArray(r->node_vao); + + // Quad VBO (location 0) + glGenBuffers(1, &r->node_quad_vbo); + glBindBuffer(GL_ARRAY_BUFFER, r->node_quad_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad_verts), quad_verts, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); + + // Instance VBO (location 1,2,3 — position, size, color) + glGenBuffers(1, &r->node_instance_vbo); + glBindBuffer(GL_ARRAY_BUFFER, r->node_instance_vbo); + // layout: x, y, size, r, g, b, a — 7 floats per instance + glEnableVertexAttribArray(1); // pos + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)0); + glVertexAttribDivisor(1, 1); + glEnableVertexAttribArray(2); // size + glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)(2 * sizeof(float))); + glVertexAttribDivisor(2, 1); + glEnableVertexAttribArray(3); // color + glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)(3 * sizeof(float))); + glVertexAttribDivisor(3, 1); + + glBindVertexArray(0); + + // --- Edge VAO --- + // Each edge: 2 vertices x (x, y, r, g, b, a) = 2 * 6 floats + glGenVertexArrays(1, &r->edge_vao); + glBindVertexArray(r->edge_vao); + + glGenBuffers(1, &r->edge_vbo); + glBindBuffer(GL_ARRAY_BUFFER, r->edge_vbo); + glEnableVertexAttribArray(0); // pos + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); + glEnableVertexAttribArray(1); // color + glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(2 * sizeof(float))); + + glBindVertexArray(0); + + // --- Shaders --- + r->node_shader = link_program(k_node_vert, k_node_frag); + r->edge_shader = link_program(k_edge_vert, k_edge_frag); + + return r; +} + +void graph_renderer_destroy(GraphRenderer* r) { + if (!r) return; + destroy_fbo(r); + glDeleteVertexArrays(1, &r->node_vao); + glDeleteBuffers(1, &r->node_quad_vbo); + glDeleteBuffers(1, &r->node_instance_vbo); + glDeleteVertexArrays(1, &r->edge_vao); + glDeleteBuffers(1, &r->edge_vbo); + glDeleteProgram(r->node_shader); + glDeleteProgram(r->edge_shader); + delete r; +} + +void graph_renderer_resize(GraphRenderer* r, int width, int height) { + if (!r) return; + if (r->width == width && r->height == height) return; + r->width = width; + r->height = height; + destroy_fbo(r); + create_fbo(r); +} + +unsigned int graph_renderer_draw(GraphRenderer* r, const GraphData& graph, + float cam_x, float cam_y, float cam_zoom) { + if (!r) return 0; + + // --- Save GL state --- + GLint prev_fbo; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prev_fbo); + GLint prev_viewport[4]; + glGetIntegerv(GL_VIEWPORT, prev_viewport); + + // --- Bind FBO --- + glBindFramebuffer(GL_FRAMEBUFFER, r->fbo); + glViewport(0, 0, r->width, r->height); + + // Clear with bg_color (ABGR) + float bg_a, bg_b, bg_g, bg_cr; + abgr_to_rgba(r->config.bg_color, bg_cr, bg_g, bg_b, bg_a); + glClearColor(bg_cr, bg_g, bg_b, bg_a); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + // Enable blending for anti-aliasing and transparency + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + // View transform: world -> screen pixels + // tx = -cam_x * scale + width/2 + // ty = -cam_y * scale + height/2 + float scale = cam_zoom; + float tx = -cam_x * scale + (float)r->width * 0.5f; + float ty = -cam_y * scale + (float)r->height * 0.5f; + + // ---------------------------------------------------------------- + // Draw edges + // ---------------------------------------------------------------- + if (graph.edge_count > 0 && graph.edges && graph.nodes) { + // Pack: 2 vertices per edge, each vertex = (x, y, r, g, b, a) = 6 floats + const int floats_per_edge = 2 * 6; + float* edge_buf = (float*)malloc((size_t)graph.edge_count * floats_per_edge * sizeof(float)); + int vi = 0; + for (int i = 0; i < graph.edge_count; ++i) { + const GraphEdge& e = graph.edges[i]; + uint32_t ecol = e.color != 0 ? e.color : 0xFF888888u; // default gray + float er, eg, eb, ea; + abgr_to_rgba(ecol, er, eg, eb, ea); + ea *= r->config.edge_alpha; + + if (e.source < (uint32_t)graph.node_count && e.target < (uint32_t)graph.node_count) { + const GraphNode& ns = graph.nodes[e.source]; + const GraphNode& nt = graph.nodes[e.target]; + + // Source vertex + edge_buf[vi++] = ns.x; edge_buf[vi++] = ns.y; + edge_buf[vi++] = er; edge_buf[vi++] = eg; + edge_buf[vi++] = eb; edge_buf[vi++] = ea; + // Target vertex + edge_buf[vi++] = nt.x; edge_buf[vi++] = nt.y; + edge_buf[vi++] = er; edge_buf[vi++] = eg; + edge_buf[vi++] = eb; edge_buf[vi++] = ea; + } + } + + glUseProgram(r->edge_shader); + glUniform2f(glGetUniformLocation(r->edge_shader, "u_viewport"), (float)r->width, (float)r->height); + glUniform1f(glGetUniformLocation(r->edge_shader, "u_scale"), scale); + glUniform2f(glGetUniformLocation(r->edge_shader, "u_translate"), tx, ty); + + glLineWidth(r->config.edge_width); + + glBindVertexArray(r->edge_vao); + glBindBuffer(GL_ARRAY_BUFFER, r->edge_vbo); + glBufferData(GL_ARRAY_BUFFER, vi * (int)sizeof(float), edge_buf, GL_DYNAMIC_DRAW); + glDrawArrays(GL_LINES, 0, vi / 6); + glBindVertexArray(0); + + free(edge_buf); + } + + // ---------------------------------------------------------------- + // Draw nodes (instanced quads) + // ---------------------------------------------------------------- + if (graph.node_count > 0 && graph.nodes) { + // Pack: 7 floats per node: x, y, size, r, g, b, a + float* node_buf = (float*)malloc((size_t)graph.node_count * 7 * sizeof(float)); + for (int i = 0; i < graph.node_count; ++i) { + const GraphNode& n = graph.nodes[i]; + uint32_t ncol = n.color != 0 ? n.color : k_palette[n.community % 10]; + float nr, ng, nb, na; + abgr_to_rgba(ncol, nr, ng, nb, na); + + float sz = n.size > 0.0f ? n.size : 4.0f; + float* p = node_buf + i * 7; + p[0] = n.x; p[1] = n.y; p[2] = sz; + p[3] = nr; p[4] = ng; p[5] = nb; p[6] = na; + } + + glUseProgram(r->node_shader); + glUniform2f(glGetUniformLocation(r->node_shader, "u_viewport"), (float)r->width, (float)r->height); + glUniform1f(glGetUniformLocation(r->node_shader, "u_scale"), scale); + glUniform2f(glGetUniformLocation(r->node_shader, "u_translate"), tx, ty); + glUniform1f(glGetUniformLocation(r->node_shader, "u_outline_px"), r->config.node_outline); + + glBindVertexArray(r->node_vao); + glBindBuffer(GL_ARRAY_BUFFER, r->node_instance_vbo); + glBufferData(GL_ARRAY_BUFFER, graph.node_count * 7 * (int)sizeof(float), node_buf, GL_DYNAMIC_DRAW); + + // Draw 4 vertices (triangle strip quad) x node_count instances + // Pass per-instance node_px uniform via the average size (approximation) + // For exact per-node pixel size we'd need a texture or another approach; + // use a uniform average for AA quality — good enough for most graphs. + float avg_px = 8.0f * scale; // rough estimate + glUniform1f(glGetUniformLocation(r->node_shader, "u_node_px"), avg_px); + + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, graph.node_count); + glBindVertexArray(0); + + free(node_buf); + } + + // --- Restore GL state --- + glDisable(GL_BLEND); + glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo); + glViewport(prev_viewport[0], prev_viewport[1], prev_viewport[2], prev_viewport[3]); + + return r->texture; +} diff --git a/cpp/functions/viz/graph_renderer.h b/cpp/functions/viz/graph_renderer.h new file mode 100644 index 00000000..eccf17b7 --- /dev/null +++ b/cpp/functions/viz/graph_renderer.h @@ -0,0 +1,28 @@ +#pragma once +#include + +struct GraphData; // forward declare + +struct GraphRendererConfig { + float node_outline = 1.5f; // outline width in pixels + float edge_width = 1.0f; // edge line width + float edge_alpha = 0.4f; // edge transparency + uint32_t bg_color = 0xFF1A1A1E; // ABGR background + bool edge_fade_alpha = true; // fade edge alpha by distance to camera + // Default palette for communities (when node.color == 0) + // 10 distinct colors, ABGR packed +}; + +struct GraphRenderer; + +// Lifecycle +GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config = {}); +void graph_renderer_destroy(GraphRenderer* r); +void graph_renderer_resize(GraphRenderer* r, int width, int height); + +// Render graph to internal FBO. +// cam_x, cam_y: camera center in graph space +// cam_zoom: zoom level (1.0 = 1:1 pixel mapping) +// Returns OpenGL texture ID suitable for ImGui::Image(). +unsigned int graph_renderer_draw(GraphRenderer* r, const GraphData& graph, + float cam_x, float cam_y, float cam_zoom); diff --git a/cpp/functions/viz/graph_renderer.md b/cpp/functions/viz/graph_renderer.md new file mode 100644 index 00000000..f6569dc1 --- /dev/null +++ b/cpp/functions/viz/graph_renderer.md @@ -0,0 +1,87 @@ +--- +name: graph_renderer +kind: function +lang: cpp +domain: viz +version: "1.0.0" +purity: impure +signature: "GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config)" +description: "Renderer GPU de grafos con instanced rendering a FBO, compatible con ImGui::Image para visualizacion de grafos grandes" +tags: [graph, renderer, opengl, gpu, instanced, fbo, visualization] +uses_functions: [] +uses_types: ["GraphData_cpp_viz"] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/graph_renderer.cpp" +framework: imgui +params: + - name: width + desc: "Ancho del framebuffer en pixels" + - name: height + desc: "Alto del framebuffer en pixels" + - name: config + desc: "Configuracion visual: outline width, edge width, edge alpha, color de fondo, fade de aristas por distancia a camara" +output: "Handle opaco al renderer. Usar graph_renderer_draw() para obtener texture ID de OpenGL, pasable directamente a ImGui::Image()" +--- + +# graph_renderer + +Renderer GPU de grafos basado en OpenGL 3.3 core profile con instanced rendering. Renderiza nodos y aristas de un `GraphData` a un FBO interno y retorna el texture ID para integracion directa con `ImGui::Image()`. + +## Funciones del API + +```cpp +// Ciclo de vida +GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config = {}); +void graph_renderer_destroy(GraphRenderer* r); +void graph_renderer_resize(GraphRenderer* r, int width, int height); + +// Renderizado +unsigned int graph_renderer_draw(GraphRenderer* r, const GraphData& graph, + float cam_x, float cam_y, float cam_zoom); +``` + +## Ejemplo de uso con ImGui + +```cpp +// Inicializacion (una vez) +GraphRenderer* renderer = graph_renderer_create(800, 600); + +// En el render loop +ImVec2 panel_size = ImGui::GetContentRegionAvail(); +graph_renderer_resize(renderer, (int)panel_size.x, (int)panel_size.y); + +unsigned int tex = graph_renderer_draw(renderer, graph_data, + cam_x, cam_y, cam_zoom); + +ImGui::Image((ImTextureID)(uintptr_t)tex, + panel_size, + ImVec2(0, 1), ImVec2(1, 0)); // flip Y para OpenGL + +// Destruccion +graph_renderer_destroy(renderer); +``` + +## Notas de implementacion + +**Renderizado de nodos:** Instanced rendering con un quad unitario [-0.5, 0.5] expandido por el tamano del nodo. El fragment shader aplica un SDF circular con anti-aliasing via `smoothstep` y un anillo de outline. + +**Renderizado de aristas:** `GL_LINES` con datos de posicion y color empaquetados por arista. El ancho se controla con `GraphRendererConfig::edge_width`. + +**Transformacion de camara:** +``` +tx = -cam_x * zoom + width/2 +ty = -cam_y * zoom + height/2 +ndc = (screen / viewport) * 2 - 1 +``` + +**Paleta de comunidades:** 10 colores ABGR usados cuando `node.color == 0`, seleccionados por `node.community % 10`. + +**Estado GL:** Guarda y restaura `GL_FRAMEBUFFER_BINDING` y `GL_VIEWPORT` para ser compatible con el render loop de ImGui sin efectos secundarios. + +**Includes GL:** Usa `#define GL_GLEXT_PROTOTYPES` + `` + ``. Si el proyecto carga funciones GL via glad/gl3w, reemplazar estos includes por el loader correspondiente. diff --git a/cpp/functions/viz/graph_types.cpp b/cpp/functions/viz/graph_types.cpp new file mode 100644 index 00000000..4a3c7e9b --- /dev/null +++ b/cpp/functions/viz/graph_types.cpp @@ -0,0 +1,24 @@ +#include "graph_types.h" +#include + +void GraphData::update_bounds() { + if (node_count == 0) { + min_x = min_y = max_x = max_y = 0.0f; + return; + } + min_x = max_x = nodes[0].x; + min_y = max_y = nodes[0].y; + for (int i = 1; i < node_count; ++i) { + if (nodes[i].x < min_x) min_x = nodes[i].x; + if (nodes[i].x > max_x) max_x = nodes[i].x; + if (nodes[i].y < min_y) min_y = nodes[i].y; + if (nodes[i].y > max_y) max_y = nodes[i].y; + } +} + +int GraphData::find_node(uint32_t id) const { + for (int i = 0; i < node_count; ++i) { + if (nodes[i].id == id) return i; + } + return -1; +} diff --git a/cpp/functions/viz/graph_types.h b/cpp/functions/viz/graph_types.h new file mode 100644 index 00000000..672ff1cf --- /dev/null +++ b/cpp/functions/viz/graph_types.h @@ -0,0 +1,50 @@ +#pragma once +#include + +// --- Graph node --- +struct GraphNode { + uint32_t id; + float x, y; // position in layout space + float vx, vy; // velocity (used by force layout) + float size; // visual radius (default 4.0) + uint32_t color; // ABGR packed (0 = use default palette) + const char* label; // optional display label (nullptr = none) + uint32_t community; // group/cluster ID (for auto-coloring) + float value; // arbitrary metric (for sizing) + bool pinned; // if true, force layout won't move this node +}; + +// --- Graph edge --- +struct GraphEdge { + uint32_t source; // index into GraphData::nodes + uint32_t target; // index into GraphData::nodes + float weight; // edge weight (affects attraction force) + uint32_t color; // ABGR packed (0 = default gray) +}; + +// --- Graph container --- +struct GraphData { + GraphNode* nodes; + int node_count; + GraphEdge* edges; + int edge_count; + + // Bounding box (updated by layout) + float min_x, min_y, max_x, max_y; + + // Recompute bounding box from node positions + void update_bounds(); + + // Find node index by id. Returns -1 if not found. + int find_node(uint32_t id) const; +}; + +// --- Helper: create a default node --- +inline GraphNode graph_node(uint32_t id, float x = 0, float y = 0) { + return {id, x, y, 0, 0, 4.0f, 0, nullptr, 0, 0, false}; +} + +// --- Helper: create an edge --- +inline GraphEdge graph_edge(uint32_t source, uint32_t target, float weight = 1.0f) { + return {source, target, weight, 0}; +} diff --git a/cpp/functions/viz/graph_viewport.cpp b/cpp/functions/viz/graph_viewport.cpp new file mode 100644 index 00000000..b754f55d --- /dev/null +++ b/cpp/functions/viz/graph_viewport.cpp @@ -0,0 +1,327 @@ +#include "viz/graph_viewport.h" +#include "viz/graph_types.h" +#include "viz/graph_renderer.h" +#include "viz/graph_force_layout.h" +#include "core/graph_spatial_hash.h" +#include "imgui.h" + +#include // snprintf +#include // memset +#include + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +static void viewport_to_graph(float vx, float vy, + float widget_x, float widget_y, + float widget_w, float widget_h, + float cam_x, float cam_y, float zoom, + float& gx, float& gy) +{ + gx = (vx - widget_x - widget_w * 0.5f) / zoom + cam_x; + gy = (vy - widget_y - widget_h * 0.5f) / zoom + cam_y; +} + +// --------------------------------------------------------------------------- +// graph_viewport_fit +// --------------------------------------------------------------------------- + +void graph_viewport_fit(GraphData& graph, GraphViewportState& state) +{ + graph.update_bounds(); + if (graph.node_count == 0) { + state.cam_x = 0.0f; + state.cam_y = 0.0f; + state.zoom = 1.0f; + return; + } + + float cx = (graph.min_x + graph.max_x) * 0.5f; + float cy = (graph.min_y + graph.max_y) * 0.5f; + state.cam_x = cx; + state.cam_y = cy; + + float span_x = graph.max_x - graph.min_x; + float span_y = graph.max_y - graph.min_y; + float span = (span_x > span_y ? span_x : span_y); + + // Use render dimensions if available; fall back to a safe default. + float view_px = (state.render_w > 0 ? (float)state.render_w : 600.0f); + float view_py = (state.render_h > 0 ? (float)state.render_h : 400.0f); + float view_min = (view_px < view_py ? view_px : view_py); + + if (span > 0.0f) { + state.zoom = (view_min * 0.9f) / span; + } else { + state.zoom = 1.0f; + } + + // Clamp to allowed range + if (state.zoom < state.zoom_min) state.zoom = state.zoom_min; + if (state.zoom > state.zoom_max) state.zoom = state.zoom_max; +} + +// --------------------------------------------------------------------------- +// graph_viewport_destroy +// --------------------------------------------------------------------------- + +void graph_viewport_destroy(GraphViewportState& state) +{ + if (state.renderer) { + graph_renderer_destroy(state.renderer); + state.renderer = nullptr; + } + if (state.spatial) { + delete state.spatial; + state.spatial = nullptr; + } + state.initialized = false; +} + +// --------------------------------------------------------------------------- +// graph_viewport — main widget +// --------------------------------------------------------------------------- + +bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, + ImVec2 size) +{ + bool interacted = false; + + // Resolve size + ImVec2 avail = ImGui::GetContentRegionAvail(); + float w = (size.x > 0.0f) ? size.x : avail.x; + float h = (size.y > 0.0f) ? size.y : avail.y; + if (w < 1.0f) w = 1.0f; + if (h < 1.0f) h = 1.0f; + + int iw = (int)w, ih = (int)h; + + // ------------------------------------------------------------------- + // 1. Lazy init + // ------------------------------------------------------------------- + if (!state.initialized) { + state.renderer = graph_renderer_create(iw, ih); + state.spatial = new SpatialHash(20.0f, 4096); + state.render_w = iw; + state.render_h = ih; + state.initialized = true; + graph_viewport_fit(graph, state); + } + + // ------------------------------------------------------------------- + // 2. Resize + // ------------------------------------------------------------------- + if (iw != state.render_w || ih != state.render_h) { + graph_renderer_resize(state.renderer, iw, ih); + state.render_w = iw; + state.render_h = ih; + } + + // ------------------------------------------------------------------- + // 3. Force layout step + // ------------------------------------------------------------------- + if (state.layout_running && graph.node_count > 0) { + state.layout_energy = graph_force_layout_step(graph); + if (state.layout_energy < 0.01f) { + state.layout_running = false; + } + } + + // ------------------------------------------------------------------- + // 4. Build spatial hash + // ------------------------------------------------------------------- + if (graph.node_count > 0) { + static std::vector xs_buf, ys_buf, sz_buf; + xs_buf.resize(graph.node_count); + ys_buf.resize(graph.node_count); + sz_buf.resize(graph.node_count); + for (int i = 0; i < graph.node_count; ++i) { + xs_buf[i] = graph.nodes[i].x; + ys_buf[i] = graph.nodes[i].y; + sz_buf[i] = graph.nodes[i].size; + } + state.spatial->build(xs_buf.data(), ys_buf.data(), sz_buf.data(), graph.node_count); + } + + // ------------------------------------------------------------------- + // 5. Invisible button to capture input + // ------------------------------------------------------------------- + ImGui::PushID(id); + + ImVec2 widget_pos = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("canvas", ImVec2(w, h), + ImGuiButtonFlags_MouseButtonLeft | + ImGuiButtonFlags_MouseButtonMiddle| + ImGuiButtonFlags_MouseButtonRight); + + bool hovered = ImGui::IsItemHovered(); + bool lm_down = ImGui::IsMouseDown(ImGuiMouseButton_Left); + bool lm_click = ImGui::IsMouseClicked(ImGuiMouseButton_Left); + bool mm_down = ImGui::IsMouseDown(ImGuiMouseButton_Middle); + bool rm_down = ImGui::IsMouseDown(ImGuiMouseButton_Right); + + ImVec2 mouse_pos = ImGui::GetMousePos(); + float mx = mouse_pos.x, my = mouse_pos.y; + + // Convert mouse to graph space + float gx_mouse, gy_mouse; + viewport_to_graph(mx, my, + widget_pos.x, widget_pos.y, w, h, + state.cam_x, state.cam_y, state.zoom, + gx_mouse, gy_mouse); + + // ------------------------------------------------------------------- + // 5a. Pan (middle or right mouse drag) + // ------------------------------------------------------------------- + if (hovered && (mm_down || rm_down)) { + ImVec2 delta = ImGui::GetIO().MouseDelta; + if (delta.x != 0.0f || delta.y != 0.0f) { + state.cam_x -= delta.x / state.zoom; + state.cam_y -= delta.y / state.zoom; + interacted = true; + } + } + + // ------------------------------------------------------------------- + // 5b. Zoom (scroll wheel) + // ------------------------------------------------------------------- + if (hovered) { + float wheel = ImGui::GetIO().MouseWheel; + if (wheel != 0.0f) { + float old_zoom = state.zoom; + float new_zoom = old_zoom * (1.0f + wheel * 0.1f); + if (new_zoom < state.zoom_min) new_zoom = state.zoom_min; + if (new_zoom > state.zoom_max) new_zoom = state.zoom_max; + + // Zoom toward cursor: keep gx_mouse/gy_mouse fixed in graph space + float rel_x = (mx - widget_pos.x - w * 0.5f); + float rel_y = (my - widget_pos.y - h * 0.5f); + state.cam_x += rel_x / old_zoom - rel_x / new_zoom; + state.cam_y += rel_y / old_zoom - rel_y / new_zoom; + state.zoom = new_zoom; + interacted = true; + } + } + + // ------------------------------------------------------------------- + // 5c. Hover — query nearest node + // ------------------------------------------------------------------- + state.hovered_node = -1; + if (hovered && graph.node_count > 0) { + float hit_radius = 10.0f / state.zoom; + int nearest = state.spatial->query_nearest(gx_mouse, gy_mouse, hit_radius); + if (nearest >= 0) { + state.hovered_node = nearest; + interacted = true; + } + } + + // ------------------------------------------------------------------- + // 5d. Node drag (left mouse down on a node) + // ------------------------------------------------------------------- + if (hovered && lm_down) { + if (state.drag_node == -1 && state.hovered_node >= 0) { + state.drag_node = state.hovered_node; + state.is_dragging = true; + } + } else { + // Release drag + if (state.drag_node >= 0 && state.drag_node < graph.node_count) { + graph.nodes[state.drag_node].pinned = false; + } + state.drag_node = -1; + state.is_dragging = false; + } + + if (state.drag_node >= 0 && state.drag_node < graph.node_count) { + GraphNode& n = graph.nodes[state.drag_node]; + n.x = gx_mouse; + n.y = gy_mouse; + n.vx = 0.0f; + n.vy = 0.0f; + n.pinned = true; + interacted = true; + } + + // ------------------------------------------------------------------- + // 5e. Click — select node + // ------------------------------------------------------------------- + if (hovered && lm_click && state.drag_node == -1) { + state.selected_node = state.hovered_node; + interacted = true; + } + + // ------------------------------------------------------------------- + // 5f. Keyboard shortcuts (only when widget is active/hovered) + // ------------------------------------------------------------------- + if (hovered) { + if (ImGui::IsKeyPressed(ImGuiKey_Space)) { + state.layout_running = !state.layout_running; + } + if (ImGui::IsKeyPressed(ImGuiKey_F)) { + graph_viewport_fit(graph, state); + interacted = true; + } + } + + // ------------------------------------------------------------------- + // 6. Render to GPU texture + // ------------------------------------------------------------------- + unsigned int tex_id = graph_renderer_draw(state.renderer, graph, + state.cam_x, state.cam_y, + state.zoom); + + // ------------------------------------------------------------------- + // 7. Display texture (flip UV for OpenGL FBO convention) + // ------------------------------------------------------------------- + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + draw_list->AddImage( + (ImTextureID)(intptr_t)tex_id, + widget_pos, + ImVec2(widget_pos.x + w, widget_pos.y + h), + ImVec2(0.0f, 1.0f), // UV top-left (flipped Y) + ImVec2(1.0f, 0.0f) // UV bottom-right + ); + + // ------------------------------------------------------------------- + // 8. Tooltip on hovered node + // ------------------------------------------------------------------- + if (state.hovered_node >= 0 && state.hovered_node < graph.node_count) { + const GraphNode& n = graph.nodes[state.hovered_node]; + + // Count degree + int degree = 0; + for (int i = 0; i < graph.edge_count; ++i) { + if ((int)graph.edges[i].source == state.hovered_node || + (int)graph.edges[i].target == state.hovered_node) { + ++degree; + } + } + + ImGui::BeginTooltip(); + if (n.label) ImGui::TextUnformatted(n.label); + ImGui::Text("ID: %u", n.id); + ImGui::Text("Community: %u", n.community); + ImGui::Text("Degree: %d", degree); + ImGui::Text("Value: %.3f", n.value); + ImGui::EndTooltip(); + } + + // ------------------------------------------------------------------- + // 9. Status bar overlay + // ------------------------------------------------------------------- + { + char status[128]; + snprintf(status, sizeof(status), + "Nodes: %d | Edges: %d | Zoom: %.2fx | Energy: %.4f | [Space] layout [F] fit", + graph.node_count, graph.edge_count, + state.zoom, state.layout_energy); + + ImVec2 text_pos = ImVec2(widget_pos.x + 6.0f, widget_pos.y + h - 18.0f); + draw_list->AddText(text_pos, IM_COL32(180, 180, 180, 200), status); + } + + ImGui::PopID(); + return interacted; +} diff --git a/cpp/functions/viz/graph_viewport.h b/cpp/functions/viz/graph_viewport.h new file mode 100644 index 00000000..5ac67ff3 --- /dev/null +++ b/cpp/functions/viz/graph_viewport.h @@ -0,0 +1,50 @@ +#pragma once +#include "imgui.h" + +struct GraphData; +struct GraphRenderer; +struct SpatialHash; + +// Persistent state for graph_viewport widget. Create one per viewport and keep +// alive across frames. +struct GraphViewportState { + // Camera + float cam_x = 0.0f, cam_y = 0.0f; + float zoom = 1.0f; + float zoom_min = 0.01f, zoom_max = 50.0f; + + // Interaction result (read after calling graph_viewport each frame) + int hovered_node = -1; // node index under cursor, -1 if none + int selected_node = -1; // last clicked node index, -1 if none + bool is_dragging = false; + + // Layout + bool layout_running = true; // animate force layout each frame + float layout_energy = 0.0f; // kinetic energy from last step + + // Internal — managed by graph_viewport / graph_viewport_destroy + GraphRenderer* renderer = nullptr; + SpatialHash* spatial = nullptr; + bool initialized = false; + + // Widget pixel dimensions tracked for resize detection + int render_w = 0, render_h = 0; + + // Node being dragged (-1 = none) + int drag_node = -1; +}; + +// Main viewport widget. Call every ImGui frame. +// id: unique ImGui widget identifier +// graph: mutable graph data (node positions updated on drag) +// state: persistent state (camera, selection, GPU renderer); must outlive frames +// size: widget size in pixels — ImVec2(0,0) uses all available space +// Returns true if any user interaction occurred (hover, click, drag, zoom). +bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, + ImVec2 size = ImVec2(0.0f, 0.0f)); + +// Release GPU resources. Call once when done with the viewport. +void graph_viewport_destroy(GraphViewportState& state); + +// Fit camera to current graph bounds with 10% padding. +void graph_viewport_fit(GraphData& graph, GraphViewportState& state); diff --git a/cpp/functions/viz/graph_viewport.md b/cpp/functions/viz/graph_viewport.md new file mode 100644 index 00000000..d3a0c611 --- /dev/null +++ b/cpp/functions/viz/graph_viewport.md @@ -0,0 +1,119 @@ +--- +name: graph_viewport +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: impure +signature: "bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, ImVec2 size)" +description: "Widget ImGui completo para visualizacion interactiva de grafos con pan, zoom, hover, seleccion y layout en vivo" +tags: [graph, viewport, imgui, interactive, pan, zoom, dashboard] +uses_functions: ["graph_renderer_cpp_viz", "graph_force_layout_cpp_viz", "graph_spatial_hash_cpp_core"] +uses_types: ["GraphData_cpp_viz"] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/graph_viewport.cpp" +framework: imgui +props: + - name: id + type: "const char*" + required: true + description: "Identificador unico del widget ImGui" + - name: graph + type: "GraphData&" + required: true + description: "Referencia al grafo (lectura de datos, escritura de posiciones al drag)" + - name: state + type: "GraphViewportState&" + required: true + description: "Estado persistente del viewport (camera, seleccion, renderer). Debe vivir mas que los frames." + - name: size + type: "ImVec2" + required: false + description: "Tamanio del widget en pixeles. ImVec2(0,0) usa todo el espacio disponible." +emits: [] +has_state: true +params: + - name: id + desc: "Identificador unico del widget ImGui. Debe ser estable entre frames." + - name: graph + desc: "Grafo a visualizar. Las posiciones de nodos se modifican al arrastrar." + - name: state + desc: "Estado persistente: camara (cam_x, cam_y, zoom), nodo seleccionado/hovereado, renderer GPU, spatial hash. Alojado por el caller." + - name: size + desc: "Tamanio del widget en pixeles. (0,0) ocupa todo el espacio disponible en la ventana ImGui." +output: "true si hubo alguna interaccion del usuario en el frame actual (hover, click, drag, zoom, teclado)" +--- + +# graph_viewport + +Widget ImGui self-contained para visualizar grafos interactivos. Integra rendering GPU, force-directed layout y hit-testing espacial en una sola llamada por frame. + +## Uso basico + +```cpp +// Declarar estado persistente (fuera del loop de render) +GraphViewportState vp_state; + +// En el loop de render (dentro de una ventana ImGui): +if (graph_viewport("mi_grafo", my_graph, vp_state)) { + // hubo interaccion este frame + if (vp_state.selected_node >= 0) { + auto& n = my_graph.nodes[vp_state.selected_node]; + // mostrar panel de detalle de n + } +} + +// Al terminar: +graph_viewport_destroy(vp_state); +``` + +## Estado de camara + +La camara usa coordenadas del espacio del grafo: + +- `cam_x`, `cam_y`: centro de la camara en espacio del grafo +- `zoom`: pixeles por unidad de grafo + +`graph_viewport_fit()` centra y ajusta el zoom para que el grafo quepa con 10% de padding. + +## Controles + +| Accion | Control | +|--------|---------| +| Pan | Boton medio o derecho + arrastrar | +| Zoom | Rueda del raton (hacia el cursor) | +| Seleccionar nodo | Click izquierdo | +| Arrastrar nodo | Click izquierdo sobre nodo | +| Toggle layout | Barra espaciadora | +| Fit camara | F | + +## Force layout + +El layout se ejecuta automaticamente cada frame mientras `state.layout_running == true`. Se detiene solo cuando la energia cinetica cae por debajo de `0.01`. Se puede pausar/reanudar con la barra espaciadora. + +Los nodos arrastrados se marcan como `pinned = true` durante el drag, impidiendo que el force layout los mueva. Al soltar, `pinned` vuelve a `false`. + +## Tooltip + +Al hacer hover sobre un nodo se muestra un tooltip con: label, id numerico, community, degree (aristas conectadas) y value. + +## Status bar + +En la parte inferior del widget aparece: numero de nodos, aristas, zoom actual, energia del layout y recordatorio de atajos de teclado. + +## Inicializacion lazy + +El renderer OpenGL y el spatial hash se crean en el primer frame. La camara se ajusta automaticamente con `graph_viewport_fit` en la inicializacion. + +## Notas de implementacion + +- Usa `ImGui::InvisibleButton` con flags para los tres botones del raton, capturando input sin dibujar ningun boton visible. +- La textura del renderer se muestra con UV volteado en Y (`ImVec2(0,1)` a `ImVec2(1,0)`) para corregir la convencion de coordenadas de OpenGL vs ImGui. +- El spatial hash se reconstruye cada frame desde las posiciones actuales de los nodos, garantizando hit-testing correcto despues de drag o layout. +- El zoom hacia el cursor mantiene el punto del grafo bajo el cursor fijo en pantalla ajustando `cam_x`/`cam_y`. diff --git a/cpp/functions/viz/histogram.cpp b/cpp/functions/viz/histogram.cpp new file mode 100644 index 00000000..3665fb8f --- /dev/null +++ b/cpp/functions/viz/histogram.cpp @@ -0,0 +1,18 @@ +#include "viz/histogram.h" +#include "implot.h" + +void histogram(const char* title, const float* values, int count, int bins) { + if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) { + int b = (bins > 0) ? bins : ImPlotBin_Sturges; + ImPlot::PlotHistogram("##data", values, count, b); + ImPlot::EndPlot(); + } +} + +void histogram(const char* title, const double* values, int count, int bins) { + if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) { + int b = (bins > 0) ? bins : ImPlotBin_Sturges; + ImPlot::PlotHistogram("##data", values, count, b); + ImPlot::EndPlot(); + } +} diff --git a/cpp/functions/viz/histogram.h b/cpp/functions/viz/histogram.h new file mode 100644 index 00000000..1f89270f --- /dev/null +++ b/cpp/functions/viz/histogram.h @@ -0,0 +1,7 @@ +#pragma once + +// Renders a histogram using ImPlot::PlotHistogram. +// Call within an ImGui frame. +// bins == -1: automatic bin count via Sturges' rule. +void histogram(const char* title, const float* values, int count, int bins = -1); +void histogram(const char* title, const double* values, int count, int bins = -1); diff --git a/cpp/functions/viz/histogram.md b/cpp/functions/viz/histogram.md new file mode 100644 index 00000000..3a5b7974 --- /dev/null +++ b/cpp/functions/viz/histogram.md @@ -0,0 +1,42 @@ +--- +name: histogram +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void histogram(const char* title, const float* values, int count, int bins = -1)" +description: "Renderiza un histograma con bins automaticos o manuales usando ImPlot PlotHistogram dentro de un frame ImGui" +tags: [implot, chart, visualization, gpu, histogram, distribution] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [implot] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/histogram.cpp" +framework: imgui +params: + - name: title + desc: "Titulo del histograma mostrado como cabecera del plot" + - name: values + desc: "Array de valores numericos a distribuir en bins" + - name: count + desc: "Numero de valores en el array" + - name: bins + desc: "Numero de bins. -1 = automatico via regla de Sturges (ImPlotBin_Sturges). Positivo = numero explicito de bins" +output: "Renderiza el histograma en el frame ImGui actual" +--- + +# histogram + +Wrapper atomico sobre `ImPlot::PlotHistogram` con seleccion automatica del numero de bins. + +Cuando `bins == -1` usa `ImPlotBin_Sturges`, que calcula el numero de bins como `ceil(log2(n)) + 1`. Para distribuciones con muchos valores o alta varianza puede preferirse pasar un valor explicito. + +El plot usa `ImVec2(-1, 0)` para ocupar el ancho disponible con altura automatica. + +Debe llamarse dentro del render callback de `fn::run_app`. diff --git a/cpp/functions/viz/kpi_card.cpp b/cpp/functions/viz/kpi_card.cpp new file mode 100644 index 00000000..516d3bb7 --- /dev/null +++ b/cpp/functions/viz/kpi_card.cpp @@ -0,0 +1,44 @@ +#include "kpi_card.h" +#include "sparkline.h" +#include +#include + +void kpi_card(const char* label, float value, float delta_percent, + const float* history, int history_count, + const char* format) { + ImGui::BeginGroup(); + + // Label — small, muted + ImGui::TextDisabled("%s", label); + + // Value — scaled up font + ImGui::SetWindowFontScale(1.8f); + char value_buf[64]; + snprintf(value_buf, sizeof(value_buf), format, value); + ImGui::Text("%s", value_buf); + ImGui::SetWindowFontScale(1.0f); + + // Delta badge — green up arrow / red down arrow + const bool positive = delta_percent >= 0.0f; + const ImVec4 delta_color = positive + ? ImVec4(0.20f, 0.80f, 0.35f, 1.0f) // green + : ImVec4(0.90f, 0.25f, 0.25f, 1.0f); // red + + char delta_buf[32]; + if (positive) { + snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xb2 +%.1f%%", delta_percent); + } else { + snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xbc %.1f%%", delta_percent); + } + + ImGui::PushStyleColor(ImGuiCol_Text, delta_color); + ImGui::Text("%s", delta_buf); + ImGui::PopStyleColor(); + + // Sparkline — matches delta color + if (history != nullptr && history_count > 0) { + sparkline(label, history, history_count, delta_color, 120.0f, 24.0f); + } + + ImGui::EndGroup(); +} diff --git a/cpp/functions/viz/kpi_card.h b/cpp/functions/viz/kpi_card.h new file mode 100644 index 00000000..1c9d461e --- /dev/null +++ b/cpp/functions/viz/kpi_card.h @@ -0,0 +1,16 @@ +#pragma once + +// KPI card — displays a key metric with trend. +// Usage: +// float history[] = {10, 12, 11, 15, 18, 17, 20}; +// kpi_card("Revenue", 20000.0f, 12.5f, history, 7, "$%.0f"); +// +// Shows: +// - Label (small, muted) +// - Value (large font) +// - Delta badge (green up / red down) +// - Sparkline of history + +void kpi_card(const char* label, float value, float delta_percent, + const float* history = nullptr, int history_count = 0, + const char* format = "%.1f"); diff --git a/cpp/functions/viz/kpi_card.md b/cpp/functions/viz/kpi_card.md new file mode 100644 index 00000000..0838f0a3 --- /dev/null +++ b/cpp/functions/viz/kpi_card.md @@ -0,0 +1,71 @@ +--- +name: kpi_card +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void kpi_card(const char* label, float value, float delta_percent, const float* history = nullptr, int history_count = 0, const char* format = \"%.1f\")" +description: "Card de KPI con valor grande, delta porcentual, y sparkline historico para dashboards" +tags: [imgui, kpi, card, dashboard, metrics, sparkline] +uses_functions: ["sparkline_cpp_viz"] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/kpi_card.cpp" +framework: imgui +params: + - name: label + desc: "Nombre del KPI mostrado como header muted (ej: \"Revenue\", \"Users\")" + - name: value + desc: "Valor numerico actual del KPI" + - name: delta_percent + desc: "Cambio porcentual respecto al periodo anterior (positivo = mejora, negativo = deterioro)" + - name: history + desc: "Array de valores historicos para el sparkline. Nullable — si es nullptr no se renderiza sparkline" + - name: history_count + desc: "Numero de valores en el array history" + - name: format + desc: "Formato printf para el valor principal (ej: \"$%.0f\", \"%.1f%%\", \"%.2f\")" +output: "Renderiza la card KPI completa en el frame ImGui actual: label muted, valor grande, badge delta verde/rojo con triangulo, y sparkline de 120x24px" +--- + +# kpi_card + +Card compacta para dashboards ImGui que muestra un KPI con contexto de tendencia. Combina label, valor escalado, badge de delta colorizado y sparkline historico en un grupo coherente de ~150px de ancho. + +Usa `sparkline` del registry para el historico, con el mismo color que el badge (verde si delta >= 0, rojo si delta < 0). + +Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo). + +## Ejemplo + +```cpp +float history[] = {10.0f, 12.0f, 11.0f, 15.0f, 18.0f, 17.0f, 20.0f}; +kpi_card("Revenue", 20000.0f, 12.5f, history, 7, "$%.0f"); + +// Sin sparkline +kpi_card("Error Rate", 0.3f, -15.2f, nullptr, 0, "%.2f%%"); + +// Grid de KPIs +ImGui::Columns(3, "kpis", false); +kpi_card("MAU", 1250000.0f, 3.4f, mau_history, 30); +ImGui::NextColumn(); +kpi_card("Revenue", 89400.0f, -1.2f, rev_history, 30, "$%.0f"); +ImGui::NextColumn(); +kpi_card("Churn", 2.1f, -0.3f, churn_history, 30, "%.1f%%"); +ImGui::Columns(1); +``` + +## Notas + +- El ancho total del grupo es aproximadamente 150px, apto para grids de 2-4 columnas. +- El escalado de fuente usa `SetWindowFontScale(1.8f)` — compatible con cualquier fuente cargada; no requiere fonts adicionales. +- Los caracteres UTF-8 del triangulo (`▲` U+25B2 y `▼` U+25BC) requieren que la fuente ImGui tenga el rango de simbolos geometricos cargado, o bien sustituir por ASCII (`^` y `v`). +- El color verde del delta es `ImVec4(0.20, 0.80, 0.35, 1.0)` y el rojo `ImVec4(0.90, 0.25, 0.25, 1.0)`, coherentes con los colores del sparkline subyacente. +- `BeginGroup`/`EndGroup` permite usar `SameLine()` despues de `kpi_card` y que el cursor avance correctamente. diff --git a/cpp/functions/viz/pie_chart.cpp b/cpp/functions/viz/pie_chart.cpp new file mode 100644 index 00000000..1b5a7406 --- /dev/null +++ b/cpp/functions/viz/pie_chart.cpp @@ -0,0 +1,31 @@ +#include "viz/pie_chart.h" +#include "implot.h" + +void pie_chart(const char* title, const char* const* labels, const float* values, int count, float radius) { + if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_Equal | ImPlotFlags_NoLegend)) { + ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations); + ImPlot::SetupAxesLimits(0, 1, 0, 1); + if (radius < 0.0f) { + // Donut mode: outer = |radius|, inner = 0.2 + ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, -radius, "%.1f", 90.0, ImPlotPieChartFlags_None); + } else { + float r = (radius > 0.0f) ? radius : 0.4f; + ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, r, "%.1f", 90.0, ImPlotPieChartFlags_None); + } + ImPlot::EndPlot(); + } +} + +void pie_chart(const char* title, const char* const* labels, const double* values, int count, double radius) { + if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_Equal | ImPlotFlags_NoLegend)) { + ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations); + ImPlot::SetupAxesLimits(0, 1, 0, 1); + if (radius < 0.0) { + ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, -radius, "%.1f", 90.0, ImPlotPieChartFlags_None); + } else { + double r = (radius > 0.0) ? radius : 0.4; + ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, r, "%.1f", 90.0, ImPlotPieChartFlags_None); + } + ImPlot::EndPlot(); + } +} diff --git a/cpp/functions/viz/pie_chart.h b/cpp/functions/viz/pie_chart.h new file mode 100644 index 00000000..9fcec19e --- /dev/null +++ b/cpp/functions/viz/pie_chart.h @@ -0,0 +1,7 @@ +#pragma once + +// Renders a pie or donut chart using ImPlot::PlotPieChart. +// Call within an ImGui frame. +// radius == 0: auto (0.4). radius > 0: explicit radius. radius < 0: donut mode (|radius| as outer, 0.2 as inner). +void pie_chart(const char* title, const char* const* labels, const float* values, int count, float radius = 0.0f); +void pie_chart(const char* title, const char* const* labels, const double* values, int count, double radius = 0.0); diff --git a/cpp/functions/viz/pie_chart.md b/cpp/functions/viz/pie_chart.md new file mode 100644 index 00000000..d6d69872 --- /dev/null +++ b/cpp/functions/viz/pie_chart.md @@ -0,0 +1,46 @@ +--- +name: pie_chart +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void pie_chart(const char* title, const char* const* labels, const float* values, int count, float radius = 0.0f)" +description: "Renderiza un grafico circular (pie/donut) usando ImPlot PlotPieChart dentro de un frame ImGui" +tags: [implot, chart, visualization, gpu, pie, donut] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [implot] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/pie_chart.cpp" +framework: imgui +params: + - name: title + desc: "Titulo del grafico" + - name: labels + desc: "Array de etiquetas para cada segmento del pie" + - name: values + desc: "Array de valores numericos para cada segmento" + - name: count + desc: "Numero de segmentos (longitud de labels y values)" + - name: radius + desc: "Radio del pie (0 = auto 0.4). Positivo = radio explicito. Negativo = modo donut con outer radius = |radius| e inner = 0.2" +output: "Renderiza el grafico circular en el frame ImGui actual" +--- + +# pie_chart + +Wrapper atomico sobre `ImPlot::PlotPieChart` con soporte para modo pie y modo donut. + +El eje del plot se configura con `ImPlotAxisFlags_NoDecorations` para ocultar los ejes y mostrar solo el grafico circular. El aspecto se fuerza a cuadrado con `ImPlotFlags_Equal`. + +**Modo pie** (`radius >= 0`): dibuja un pie chart solido. Si `radius == 0`, usa radio automatico de 0.4. + +**Modo donut** (`radius < 0`): usa `|radius|` como radio exterior. El agujero interior es fijo en 0.2, suficiente para texto central. + +Debe llamarse dentro del render callback de `fn::run_app`. diff --git a/cpp/functions/viz/sparkline.cpp b/cpp/functions/viz/sparkline.cpp new file mode 100644 index 00000000..ef83daa1 --- /dev/null +++ b/cpp/functions/viz/sparkline.cpp @@ -0,0 +1,76 @@ +#include "viz/sparkline.h" +#include "imgui.h" + +void sparkline(const char* id, const float* values, int count, ImVec4 color, + float width, float height) { + if (count <= 0) return; + + ImGui::PushID(id); + + ImVec2 pos = ImGui::GetCursorScreenPos(); + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + + // Reserve inline space + ImGui::Dummy(ImVec2(width, height)); + + // Find min/max for Y auto-scale + float min_val = values[0]; + float max_val = values[0]; + for (int i = 1; i < count; i++) { + if (values[i] < min_val) min_val = values[i]; + if (values[i] > max_val) max_val = values[i]; + } + float range = max_val - min_val; + if (range < 1e-6f) range = 1.0f; // avoid division by zero for flat lines + + // Fill area under curve (low alpha) + if (count >= 2) { + ImU32 fill_color = IM_COL32( + (int)(color.x * 255), + (int)(color.y * 255), + (int)(color.z * 255), + 40); + + // Build fill polygon: baseline bottom-left -> points -> baseline bottom-right + // We use AddConvexPolyFilled workaround: draw as a series of triangles from baseline + float x0 = pos.x; + float y_base = pos.y + height; + + for (int i = 0; i + 1 < count; i++) { + float xa = x0 + ((float)i / (count - 1)) * width; + float xb = x0 + ((float)(i + 1) / (count - 1)) * width; + float ya = pos.y + height - ((values[i] - min_val) / range) * height; + float yb = pos.y + height - ((values[i + 1] - min_val) / range) * height; + + draw_list->AddQuadFilled( + ImVec2(xa, y_base), + ImVec2(xa, ya), + ImVec2(xb, yb), + ImVec2(xb, y_base), + fill_color); + } + } + + // Draw polyline + ImU32 line_color = IM_COL32( + (int)(color.x * 255), + (int)(color.y * 255), + (int)(color.z * 255), + (int)(color.w * 255)); + + for (int i = 0; i + 1 < count; i++) { + float xa = pos.x + ((float)i / (count - 1)) * width; + float xb = pos.x + ((float)(i + 1) / (count - 1)) * width; + float ya = pos.y + height - ((values[i] - min_val) / range) * height; + float yb = pos.y + height - ((values[i + 1] - min_val) / range) * height; + draw_list->AddLine(ImVec2(xa, ya), ImVec2(xb, yb), line_color, 1.5f); + } + + ImGui::PopID(); +} + +void sparkline(const char* id, const float* values, int count, + float width, float height) { + // Default color: soft green + sparkline(id, values, count, ImVec4(0.35f, 0.85f, 0.45f, 1.0f), width, height); +} diff --git a/cpp/functions/viz/sparkline.h b/cpp/functions/viz/sparkline.h new file mode 100644 index 00000000..4994ea24 --- /dev/null +++ b/cpp/functions/viz/sparkline.h @@ -0,0 +1,12 @@ +#pragma once + +#include "imgui.h" + +// Renders a mini inline line chart for use in tables, headers and KPI cards. +// Auto-scales Y to the min/max of values. +// Uses PushID/PopID with id for uniqueness inside tables. +void sparkline(const char* id, const float* values, int count, + float width = 100.0f, float height = 20.0f); + +void sparkline(const char* id, const float* values, int count, ImVec4 color, + float width = 100.0f, float height = 20.0f); diff --git a/cpp/functions/viz/sparkline.md b/cpp/functions/viz/sparkline.md new file mode 100644 index 00000000..74981921 --- /dev/null +++ b/cpp/functions/viz/sparkline.md @@ -0,0 +1,69 @@ +--- +name: sparkline +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void sparkline(const char* id, const float* values, int count, float width = 100.0f, float height = 20.0f)" +description: "Renderiza un mini grafico de lineas inline para uso en tablas, headers y KPI cards" +tags: [imgui, visualization, sparkline, inline, dashboard] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/sparkline.cpp" +framework: imgui +params: + - name: id + desc: "Identificador unico del widget, usado con PushID/PopID para garantizar unicidad en tablas" + - name: values + desc: "Array de valores float del sparkline (serie temporal)" + - name: count + desc: "Numero de valores en el array" + - name: width + desc: "Ancho en pixels del sparkline (default 100.0)" + - name: height + desc: "Alto en pixels del sparkline (default 20.0)" +output: "Renderiza el sparkline inline en el frame ImGui actual, reservando espacio con ImGui::Dummy" +--- + +# sparkline + +Mini grafico de lineas inline construido sobre ImGui draw primitives. No requiere ImPlot. + +Auto-escala el eje Y al rango minimo/maximo de los valores. Dibuja una polyline con relleno semitransparente bajo la curva. Disenado para encajar en celdas de tablas, headers y tarjetas KPI. + +Ofrece dos overloads: +- Sin color: usa verde suave por defecto (`ImVec4(0.35, 0.85, 0.45, 1.0)`) +- Con color: acepta cualquier `ImVec4` para personalizar la linea y el relleno + +Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo). + +## Ejemplo + +```cpp +// En una celda de tabla +ImGui::TableNextColumn(); +sparkline("##revenue_spark", revenue.data(), (int)revenue.size(), 80.0f, 18.0f); + +// Con color personalizado (rojo para valores negativos) +sparkline("##pnl", pnl.data(), (int)pnl.size(), + ImVec4(0.9f, 0.3f, 0.3f, 1.0f), 100.0f, 20.0f); + +// KPI card inline con label +ImGui::Text("Revenue"); ImGui::SameLine(); +sparkline("kpi_rev", data, count); +``` + +## Notas + +- El relleno bajo la curva usa alpha 40/255 del mismo color de la linea. +- Si todos los valores son iguales (rango < 1e-6), la linea se dibuja en el centro verticalmente. +- El grosor de linea es 1.5px para que sea legible a alturas de 16-24px. +- `id` no se muestra visualmente; solo se pasa a `PushID` para que ImGui diferencie widgets con los mismos datos en la misma tabla. diff --git a/cpp/functions/viz/surface_plot_3d.cpp b/cpp/functions/viz/surface_plot_3d.cpp new file mode 100644 index 00000000..99c2f0bd --- /dev/null +++ b/cpp/functions/viz/surface_plot_3d.cpp @@ -0,0 +1,12 @@ +#include "viz/surface_plot_3d.h" +#include "imgui.h" + +void surface_plot_3d(const char* title, const float* values, int rows, int cols, + float z_min, float z_max) { + ImGui::BeginGroup(); + ImGui::TextDisabled("[STUB] %s", title); + ImGui::TextWrapped("surface_plot_3d requires ImPlot3D. " + "Add it to cpp/vendor/implot3d/ and rebuild."); + ImGui::Text("Data: %dx%d, range [%.2f, %.2f]", rows, cols, z_min, z_max); + ImGui::EndGroup(); +} diff --git a/cpp/functions/viz/surface_plot_3d.h b/cpp/functions/viz/surface_plot_3d.h new file mode 100644 index 00000000..9e1f43c8 --- /dev/null +++ b/cpp/functions/viz/surface_plot_3d.h @@ -0,0 +1,8 @@ +#pragma once + +// [STUB] Renders a 3D surface plot using ImPlot3D. +// Requires ImPlot3D to be vendored in cpp/vendor/implot3d/. +// Until then, displays a placeholder message inside an ImGui group. +// Call within an ImGui frame (inside fn::run_app render callback). +void surface_plot_3d(const char* title, const float* values, int rows, int cols, + float z_min, float z_max); diff --git a/cpp/functions/viz/surface_plot_3d.md b/cpp/functions/viz/surface_plot_3d.md new file mode 100644 index 00000000..940765eb --- /dev/null +++ b/cpp/functions/viz/surface_plot_3d.md @@ -0,0 +1,61 @@ +--- +name: surface_plot_3d +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void surface_plot_3d(const char* title, const float* values, int rows, int cols, float z_min, float z_max)" +description: "[STUB] Renderiza una superficie 3D — requiere ImPlot3D (no vendoreado aun)" +tags: [implot3d, chart, visualization, gpu, surface, 3d, stub] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/surface_plot_3d.cpp" +framework: imgui +params: + - name: title + desc: "Titulo de la superficie, se muestra como header del plot" + - name: values + desc: "Array row-major de alturas Z con dimension rows*cols" + - name: rows + desc: "Numero de filas de la grilla de la superficie" + - name: cols + desc: "Numero de columnas de la grilla de la superficie" + - name: z_min + desc: "Valor minimo del eje Z para escalar el colormap" + - name: z_max + desc: "Valor maximo del eje Z para escalar el colormap" +output: "Renderiza un placeholder informativo en el frame ImGui actual; cuando ImPlot3D este disponible, renderizara la superficie 3D" +--- + +# surface_plot_3d + +**STUB** — la implementacion real requiere [ImPlot3D](https://github.com/brenocq/implot3d), que todavia no esta vendoreado en `cpp/vendor/implot3d/`. + +Mientras tanto la funcion renderiza un grupo ImGui con un mensaje informativo que muestra el titulo, las dimensiones de la grilla y el rango Z. La firma es definitiva y no cambiara cuando se integre ImPlot3D. + +## Dependencia pendiente + +Para activar la implementacion real: + +1. Clonar o copiar ImPlot3D en `cpp/vendor/implot3d/` +2. Anadir `implot3d.cpp` al build system (CMake / Makefile) +3. Reemplazar el cuerpo de `surface_plot_3d` por la llamada a `ImPlot3D::BeginPlot3D` / `ImPlot3D::PlotSurface` / `ImPlot3D::EndPlot3D` +4. Actualizar `imports` del .md a `[imgui, implot3d]` y quitar el tag `stub` + +## Uso + +```cpp +// values es un array row-major de rows*cols floats +float grid[4 * 4] = { ... }; +surface_plot_3d("Mi Superficie", grid, 4, 4, -1.0f, 1.0f); +``` + +Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo). diff --git a/cpp/functions/viz/table_view.cpp b/cpp/functions/viz/table_view.cpp new file mode 100644 index 00000000..f8285467 --- /dev/null +++ b/cpp/functions/viz/table_view.cpp @@ -0,0 +1,32 @@ +#include "viz/table_view.h" +#include "imgui.h" + +bool table_view(const char* id, const char* const* headers, int col_count, const char* const* cells, int row_count) { + ImGuiTableFlags flags = + ImGuiTableFlags_Borders | + ImGuiTableFlags_Sortable | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | + ImGuiTableFlags_ScrollY | + ImGuiTableFlags_Reorderable; + + if (!ImGui::BeginTable(id, col_count, flags, ImVec2(0, 300))) { + return false; + } + + for (int col = 0; col < col_count; col++) { + ImGui::TableSetupColumn(headers[col]); + } + ImGui::TableHeadersRow(); + + for (int row = 0; row < row_count; row++) { + ImGui::TableNextRow(); + for (int col = 0; col < col_count; col++) { + ImGui::TableSetColumnIndex(col); + ImGui::TextUnformatted(cells[row * col_count + col]); + } + } + + ImGui::EndTable(); + return true; +} diff --git a/cpp/functions/viz/table_view.h b/cpp/functions/viz/table_view.h new file mode 100644 index 00000000..268264d5 --- /dev/null +++ b/cpp/functions/viz/table_view.h @@ -0,0 +1,6 @@ +#pragma once + +// Renders an interactive table with sorting indicators and scroll using the ImGui Tables API. +// Call within an ImGui frame. +// Returns true if the table was rendered visible, false if clipped/skipped. +bool table_view(const char* id, const char* const* headers, int col_count, const char* const* cells, int row_count); diff --git a/cpp/functions/viz/table_view.md b/cpp/functions/viz/table_view.md new file mode 100644 index 00000000..927ad8a6 --- /dev/null +++ b/cpp/functions/viz/table_view.md @@ -0,0 +1,67 @@ +--- +name: table_view +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "bool table_view(const char* id, const char* const* headers, int col_count, const char* const* cells, int row_count)" +description: "Renderiza una tabla interactiva con sorting y scroll usando ImGui Tables API" +tags: [imgui, table, visualization, dashboard, data] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/table_view.cpp" +framework: imgui +params: + - name: id + desc: "Identificador unico de la tabla para ImGui (debe ser unico en el frame)" + - name: headers + desc: "Array de strings con los nombres de las columnas" + - name: col_count + desc: "Numero de columnas" + - name: cells + desc: "Array flat row-major de strings; acceso a celda (row, col) via cells[row * col_count + col]" + - name: row_count + desc: "Numero de filas de datos, sin contar el header" +output: "true si la tabla se renderizo visible, false si fue clipped o skipped por ImGui" +--- + +# table_view + +Wrapper atomico sobre `ImGui::BeginTable` / `ImGui::EndTable`. Renderiza una tabla con las siguientes capacidades: + +- **Borders**: bordes entre celdas y columnas +- **Sortable**: muestra indicadores de orden en los headers (el caller es responsable de ordenar `cells` antes de llamar) +- **RowBg**: filas alternadas con color de fondo +- **Resizable**: el usuario puede arrastrar los separadores de columna +- **ScrollY**: scroll vertical con altura fija de 300px +- **Reorderable**: el usuario puede reordenar columnas arrastrando los headers + +El caller controla el orden de los datos — `table_view` solo habilita el flag `Sortable` para que ImGui muestre los indicadores visuales, pero no reordena `cells` internamente. + +Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo). + +## Ejemplo + +```cpp +const char* headers[] = {"Nombre", "Valor", "Estado"}; +const char* cells[] = { + "Alpha", "1.23", "OK", + "Beta", "4.56", "WARN", + "Gamma", "7.89", "ERROR", +}; +table_view("##mi_tabla", headers, 3, cells, 3); +``` + +## Notas + +- `id` debe comenzar con `##` si no se quiere mostrar como titulo de ventana en el contexto ImGui. +- El outer size fijo de `ImVec2(0, 300)` puede parametrizarse en una version futura. +- El sorting real de datos queda fuera del scope de esta funcion para mantenerla pura y componible. diff --git a/cpp/types/viz/graph_types.md b/cpp/types/viz/graph_types.md new file mode 100644 index 00000000..aaf50313 --- /dev/null +++ b/cpp/types/viz/graph_types.md @@ -0,0 +1,106 @@ +--- +name: GraphData +lang: cpp +domain: viz +version: "1.0.0" +algebraic: product +definition: | + struct GraphNode { + uint32_t id; + float x, y; + float vx, vy; + float size; + uint32_t color; + const char* label; + uint32_t community; + float value; + bool pinned; + }; + + struct GraphEdge { + uint32_t source; + uint32_t target; + float weight; + uint32_t color; + }; + + struct GraphData { + GraphNode* nodes; + int node_count; + GraphEdge* edges; + int edge_count; + float min_x, min_y, max_x, max_y; + void update_bounds(); + int find_node(uint32_t id) const; + }; +description: "Tipos de datos base para el sistema de grafos GPU del registry. GraphNode modela un vertice con posicion, velocidad, apariencia y metadatos de layout. GraphEdge modela una arista con peso y color. GraphData es el contenedor principal que agrupa nodos y aristas con bounding box y metodos de consulta. Disenado para integrarse con force-directed layout y renderizado GPU via ImGui/ImPlot." +tags: [graph, network, visualization, gpu, force-layout, node, edge, imgui] +uses_types: [] +file_path: "cpp/functions/viz/graph_types.h" +--- + +## Structs + +### GraphNode + +Vertice del grafo. Contiene todos los datos necesarios para el layout y el renderizado. + +| Campo | Tipo | Descripcion | +|---|---|---| +| `id` | `uint32_t` | Identificador unico del nodo | +| `x, y` | `float` | Posicion en el espacio de layout | +| `vx, vy` | `float` | Velocidad del nodo, usada por el algoritmo force-directed | +| `size` | `float` | Radio visual en pixels (por defecto 4.0) | +| `color` | `uint32_t` | Color en formato ABGR packed. 0 = usar paleta automatica por community | +| `label` | `const char*` | Etiqueta visible. `nullptr` = sin etiqueta | +| `community` | `uint32_t` | ID de grupo/cluster para auto-coloreo. 0 = sin grupo | +| `value` | `float` | Metrica arbitraria (puede usarse para escalar el tamaño del nodo) | +| `pinned` | `bool` | Si es `true`, el force layout no mueve este nodo | + +### GraphEdge + +Arista del grafo. Referencia nodos por indice (no por id) para acceso O(1) en el loop de simulacion. + +| Campo | Tipo | Descripcion | +|---|---|---| +| `source` | `uint32_t` | Indice en `GraphData::nodes` del nodo origen | +| `target` | `uint32_t` | Indice en `GraphData::nodes` del nodo destino | +| `weight` | `float` | Peso de la arista. Afecta la fuerza de atraccion en el layout | +| `color` | `uint32_t` | Color ABGR packed. 0 = gris por defecto | + +### GraphData + +Contenedor principal. Posee los arrays de nodos y aristas (memoria gestionada externamente). Mantiene un bounding box actualizable para proyeccion de coordenadas a pantalla. + +| Campo/Metodo | Descripcion | +|---|---| +| `nodes` / `node_count` | Array de nodos y su longitud | +| `edges` / `edge_count` | Array de aristas y su longitud | +| `min_x, min_y, max_x, max_y` | Bounding box calculado sobre las posiciones actuales | +| `update_bounds()` | Recalcula el bounding box iterando todos los nodos | +| `find_node(id)` | Busqueda lineal por `GraphNode::id`. Retorna -1 si no existe | + +## Helpers + +```cpp +// Crear un nodo con valores por defecto +GraphNode n = graph_node(42, 100.0f, 200.0f); + +// Crear una arista con peso por defecto 1.0 +GraphEdge e = graph_edge(0, 1, 2.5f); +``` + +## Implementacion + +Los metodos `update_bounds()` y `find_node()` estan implementados en `cpp/functions/viz/graph_types.cpp`. + +`update_bounds()` es O(n) sobre `node_count`. Llamar despues de cada step del layout para mantener el bounding box fresco. + +`find_node()` es O(n) por busqueda lineal. Para grafos grandes (>10k nodos) considerar mantener un `unordered_map` externo como indice. + +## Notas de diseño + +- La memoria de `nodes` y `edges` es propiedad del caller. `GraphData` no hace `new`/`delete`. +- `color` usa formato ABGR packed (compatible con ImGui `ImU32`): `0xAABBGGRR`. +- Las aristas referencian por indice, no por id, para que el loop de simulacion sea cache-friendly. +- `community` con valor 0 se interpreta como "sin grupo" — los colores de comunidad empiezan desde 1. diff --git a/python/functions/notebook/README.md b/python/functions/notebook/README.txt similarity index 100% rename from python/functions/notebook/README.md rename to python/functions/notebook/README.txt diff --git a/python/functions/notebook/jupyter_exec.md b/python/functions/notebook/jupyter_exec.md index f6637544..f2b89ca6 100644 --- a/python/functions/notebook/jupyter_exec.md +++ b/python/functions/notebook/jupyter_exec.md @@ -102,4 +102,6 @@ Output siempre JSON. En error retorna `{"error": "..."}` por stderr con exit cod - `jupyter_kernel_execute` es sincrona directamente porque `KernelClient.execute` es bloqueante. - El token puede ser cadena vacia si el servidor tiene autenticacion deshabilitada. - `NbModelClient` requiere que el servidor tenga habilitado el endpoint colaborativo (`/api/collaboration/`), disponible en JupyterLab >= 4 con `jupyter-collaboration` instalado. +- **Auto-init**: `jupyter_append_execute` crea el notebook automaticamente si no existe (via REST PUT /api/contents) y arranca una sesion con kernel si no hay ninguna activa para ese notebook (via POST /api/sessions). No es necesario abrir el notebook manualmente en el navegador. +- **Auto-session**: `jupyter_execute_cell` tambien garantiza que exista una sesion con kernel antes de ejecutar. - **Fix Issue 006**: `jupyter_execute_cell` normaliza la celda antes de ejecutar. Las celdas creadas manualmente (no via la UI de Jupyter) pueden carecer de `outputs` o `execution_count` en el modelo CRDT, lo que causaba `KeyError: 'outputs'` dentro de `execute_cell` al hacer `del ycell["outputs"][:]`. El fix lee la celda con `nb[cell_index]`, detecta los campos faltantes, y reemplaza la celda via `nb[cell_index] = _normalize_code_cell(cell)` — que usa `set_cell` internamente para re-crear el mapa CRDT completo preservando el source original. diff --git a/python/functions/notebook/jupyter_exec.py b/python/functions/notebook/jupyter_exec.py index fa14f5e6..01fb7e13 100644 --- a/python/functions/notebook/jupyter_exec.py +++ b/python/functions/notebook/jupyter_exec.py @@ -10,7 +10,7 @@ import asyncio import json from functools import partial from typing import Any -from urllib.error import URLError +from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from jupyter_kernel_client import KernelClient @@ -23,6 +23,80 @@ from nbformat import NotebookNode # --------------------------------------------------------------------------- +def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool: + """Comprueba si un notebook existe en el servidor Jupyter via HEAD /api/contents.""" + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + check_url = f"{server_url}/api/contents/{notebook_path}" + req = Request(check_url, headers=headers, method="HEAD") + try: + with urlopen(req, timeout=5): + return True + except HTTPError as e: + if e.code == 404: + return False + raise + + +def _create_notebook(notebook_path: str, server_url: str, token: str, kernel_name: str = "python3") -> None: + """Crea un notebook vacio via PUT /api/contents si no existe.""" + if _notebook_exists(notebook_path, server_url, token): + return + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if token: + headers["Authorization"] = f"token {token}" + kernel_display = {"python3": "Python 3 (ipykernel)", "python": "Python 3"}.get(kernel_name, kernel_name) + notebook_content = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": {"name": kernel_name, "display_name": kernel_display, "language": "python"}, + "language_info": {"name": "python"}, + }, + "cells": [], + } + body = json.dumps({"type": "notebook", "content": notebook_content}).encode("utf-8") + url = f"{server_url}/api/contents/{notebook_path}" + req = Request(url, data=body, headers=headers, method="PUT") + with urlopen(req, timeout=10) as resp: + resp.read() + + +def _ensure_session(server_url: str, token: str, notebook_path: str, kernel_name: str = "python3") -> str: + """Garantiza que exista una sesion para el notebook. Retorna el kernel_id. + + Si ya hay una sesion activa, retorna su kernel_id. Si no, crea una nueva + via POST /api/sessions (lo cual tambien arranca un kernel). + """ + kernel_id = _resolve_kernel_id(server_url, token, notebook_path) + if kernel_id: + return kernel_id + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + if token: + headers["Authorization"] = f"token {token}" + + body = json.dumps({ + "path": notebook_path, + "type": "notebook", + "kernel": {"name": kernel_name}, + }).encode("utf-8") + + url = f"{server_url}/api/sessions" + req = Request(url, data=body, headers=headers, method="POST") + with urlopen(req, timeout=10) as resp: + session = json.loads(resp.read()) + + return session.get("kernel", {}).get("id", "") + + def _api_get(url: str, token: str = "") -> dict | list | None: """GET a Jupyter REST API endpoint.""" headers = {"Accept": "application/json"} @@ -112,13 +186,14 @@ async def _async_append_execute( server_url: str, token: str, ) -> dict[str, Any]: + _create_notebook(notebook_path, server_url, token) + kernel_id = _ensure_session(server_url, token, notebook_path) + ws_url = get_jupyter_notebook_websocket_url( server_url, notebook_path, token or None, ) - - kernel_id = _resolve_kernel_id(server_url, token, notebook_path) username = _resolve_collab_username(server_url, token) async with NbModelClient(ws_url, username=username) as nb: @@ -149,12 +224,13 @@ async def _async_execute_cell( server_url: str, token: str, ) -> dict[str, Any]: + kernel_id = _ensure_session(server_url, token, notebook_path) + ws_url = get_jupyter_notebook_websocket_url( server_url, notebook_path, token or None, ) - kernel_id = _resolve_kernel_id(server_url, token, notebook_path) username = _resolve_collab_username(server_url, token) async with NbModelClient(ws_url, username=username) as nb: diff --git a/python/functions/notebook/jupyter_kernel.md b/python/functions/notebook/jupyter_kernel.md index 8c53b1b6..c529ec0b 100644 --- a/python/functions/notebook/jupyter_kernel.md +++ b/python/functions/notebook/jupyter_kernel.md @@ -6,8 +6,8 @@ domain: notebook version: "1.0.0" purity: impure signature: "def jupyter_kernel_list(server_url: str = \"http://localhost:8888\", token: str = \"\") -> list[dict]" -description: "CRUD completo de kernels Jupyter via REST API. Expone seis operaciones: list, start, restart, interrupt, shutdown y sessions. Usa solo stdlib (urllib, json), sin dependencias externas." -tags: [jupyter, notebook, kernel, api, http, rest, sessions, crud] +description: "CRUD completo de kernels Jupyter via REST API. Expone ocho operaciones: list, start, restart, interrupt, shutdown, sessions, cleanup y shutdown-all. Usa solo stdlib (urllib, json), sin dependencias externas." +tags: [jupyter, notebook, kernel, api, http, rest, sessions, crud, cleanup] uses_functions: [] uses_types: [] returns: [] @@ -31,6 +31,8 @@ file_path: "python/functions/notebook/jupyter_kernel.py" | `jupyter_kernel_interrupt(server_url, token, kernel_id)` | `POST /api/kernels/{id}/interrupt` | Interrumpe ejecucion | | `jupyter_kernel_shutdown(server_url, token, kernel_id)` | `DELETE /api/kernels/{id}` | Apaga y elimina un kernel | | `jupyter_kernel_sessions(server_url, token)` | `GET /api/sessions` | Lista sesiones activas | +| `jupyter_kernel_cleanup(server_url, token, idle_seconds)` | `GET + DELETE` | Apaga kernels inactivos | +| `jupyter_kernel_shutdown_all(server_url, token)` | `GET + DELETE` | Apaga todos los kernels | ## Ejemplo @@ -88,6 +90,12 @@ python python/functions/notebook/jupyter_kernel.py shutdown abc123-... # Listar sesiones python python/functions/notebook/jupyter_kernel.py sessions + +# Limpiar kernels inactivos (default: 1h sin actividad) +python python/functions/notebook/jupyter_kernel.py cleanup --idle-seconds 1800 + +# Apagar todos los kernels +python python/functions/notebook/jupyter_kernel.py shutdown-all ``` Todos los subcomandos aceptan `--server` y `--token`. El output es siempre JSON. diff --git a/python/functions/notebook/jupyter_kernel.py b/python/functions/notebook/jupyter_kernel.py index 7b603502..1e1bc89e 100644 --- a/python/functions/notebook/jupyter_kernel.py +++ b/python/functions/notebook/jupyter_kernel.py @@ -196,6 +196,80 @@ def jupyter_kernel_sessions( return sessions +def jupyter_kernel_cleanup( + server_url: str = "http://localhost:8888", + token: str = "", + idle_seconds: int = 3600, +) -> list[dict]: + """Apaga todos los kernels que llevan mas de idle_seconds sin actividad. + + Util para liberar recursos en servidores con muchos notebooks abiertos. + Por defecto cierra kernels inactivos desde hace mas de 1 hora. + + Args: + server_url: URL base del servidor Jupyter. + token: Token de autenticacion. Vacio si el servidor no requiere auth. + idle_seconds: Segundos de inactividad para considerar un kernel ocioso. + + Returns: + Lista de dicts con los kernels apagados (id, name, last_activity, idle_seconds). + + Raises: + urllib.error.HTTPError: Si la respuesta HTTP indica un error. + urllib.error.URLError: Si no se puede conectar al servidor. + """ + from datetime import datetime, timezone + + kernels = jupyter_kernel_list(server_url, token) + now = datetime.now(timezone.utc) + shutdown_list = [] + + for k in kernels: + last_activity = k.get("last_activity", "") + if not last_activity: + continue + try: + last_dt = datetime.fromisoformat(last_activity.replace("Z", "+00:00")) + idle = (now - last_dt).total_seconds() + except (ValueError, TypeError): + continue + if idle >= idle_seconds: + jupyter_kernel_shutdown(server_url, token, k["id"]) + shutdown_list.append({ + "id": k["id"], + "name": k.get("name", ""), + "last_activity": last_activity, + "idle_seconds": int(idle), + }) + + return shutdown_list + + +def jupyter_kernel_shutdown_all( + server_url: str = "http://localhost:8888", + token: str = "", +) -> list[dict]: + """Apaga todos los kernels activos del servidor. + + Args: + server_url: URL base del servidor Jupyter. + token: Token de autenticacion. Vacio si el servidor no requiere auth. + + Returns: + Lista de dicts con los kernels apagados (id, name). + + Raises: + urllib.error.HTTPError: Si la respuesta HTTP indica un error. + urllib.error.URLError: Si no se puede conectar al servidor. + """ + kernels = jupyter_kernel_list(server_url, token) + shutdown_list = [] + for k in kernels: + jupyter_kernel_shutdown(server_url, token, k["id"]) + shutdown_list.append({"id": k["id"], "name": k.get("name", "")}) + return shutdown_list + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -248,6 +322,18 @@ if __name__ == "__main__": # sessions subparsers.add_parser("sessions", help="Lista las sesiones activas.") + # cleanup + sp_cleanup = subparsers.add_parser("cleanup", help="Apaga kernels inactivos.") + sp_cleanup.add_argument( + "--idle-seconds", + type=int, + default=3600, + help="Segundos de inactividad para considerar ocioso (default: 3600)", + ) + + # shutdown-all + subparsers.add_parser("shutdown-all", help="Apaga todos los kernels activos.") + args = parser.parse_args() try: @@ -267,6 +353,10 @@ if __name__ == "__main__": result = {"status": "shutdown", "kernel_id": args.kernel_id} elif args.command == "sessions": result = jupyter_kernel_sessions(args.server, args.token) + elif args.command == "cleanup": + result = jupyter_kernel_cleanup(args.server, args.token, args.idle_seconds) + elif args.command == "shutdown-all": + result = jupyter_kernel_shutdown_all(args.server, args.token) else: parser.print_help() sys.exit(1) diff --git a/python/functions/notebook/jupyter_write.md b/python/functions/notebook/jupyter_write.md index 1504a0f3..9f768d54 100644 --- a/python/functions/notebook/jupyter_write.md +++ b/python/functions/notebook/jupyter_write.md @@ -153,4 +153,5 @@ python -m notebook.jupyter_write delete notebooks/01.ipynb 3 - NO ejecuta celdas — solo modifica la estructura. Para ejecutar, usar `jupyter_exec`. - `server_url` y `token` tienen defaults convenientes para desarrollo local (`http://localhost:8888`, token vacio). - El campo `cell_index` en el resultado refleja la posicion final de la celda en el notebook. -- Patron tipico: `create` para crear el notebook, luego `batch` para poblar las celdas iniciales. +- `append_code`, `append_markdown` y `batch` crean el notebook automaticamente si no existe (auto-create via REST). No es necesario llamar a `create` previamente. +- Patron tipico: `batch` para poblar las celdas iniciales (crea el notebook si no existe), o `create` + `batch` si se necesita control explicito. diff --git a/python/functions/notebook/jupyter_write.py b/python/functions/notebook/jupyter_write.py index 9a8d3ba5..751aadfa 100644 --- a/python/functions/notebook/jupyter_write.py +++ b/python/functions/notebook/jupyter_write.py @@ -30,6 +30,35 @@ def _resolve_collab_username(server_url: str, token: str) -> str: return "Anonymous" +# --------------------------------------------------------------------------- +# Helpers internos +# --------------------------------------------------------------------------- + + +def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool: + """Comprueba si un notebook existe en el servidor Jupyter via HEAD /api/contents.""" + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + check_url = f"{server_url}/api/contents/{notebook_path}" + req = Request(check_url, headers=headers, method="HEAD") + try: + with urlopen(req, timeout=5): + return True + except HTTPError as e: + if e.code == 404: + return False + raise + + +def _auto_create_notebook(notebook_path: str, server_url: str, token: str) -> bool: + """Crea el notebook si no existe. Retorna True si fue creado.""" + if not _notebook_exists(notebook_path, server_url, token): + jupyter_create_notebook(notebook_path, server_url=server_url, token=token) + return True + return False + + # --------------------------------------------------------------------------- # Helpers internos async # --------------------------------------------------------------------------- @@ -42,6 +71,7 @@ async def _append_cell( server_url: str, token: str, ) -> dict: + _auto_create_notebook(notebook_path, server_url, token) ws_url = get_jupyter_notebook_websocket_url( server_url=server_url, token=token, @@ -139,6 +169,7 @@ async def _batch_write( token: str, ) -> dict: """Anade multiples celdas en una sola conexion WebSocket.""" + _auto_create_notebook(notebook_path, server_url, token) ws_url = get_jupyter_notebook_websocket_url( server_url=server_url, token=token,