Commit Graph

36 Commits

Author SHA1 Message Date
egutierrez 616c46297b fix: promote button funciona + placement direccional mas cercano
Dos bugs reportados tras 0036d/0037:

1. Promote button en NodeGroups window kind=Group no respondia al
   click. Causa: el Selectable con SpanAllColumns + AllowDoubleClick
   se tragaba el click destinado al SmallButton(TI_ARROW_UP) sobre
   la misma fila. ImGui tiene un flag dedicado para esto:
   AllowOverlap. Anyadido al Selectable y los botones recuperan los
   clicks. Mismo fix beneficia al kind=Table porque los botones
   "promote" y "expand" de DuckDB rows estaban en la misma situacion
   silenciosa.

2. Placement direccional de 0037 enviaba hijos hasta r=400 cuando
   habia colisiones, "muy lejos" segun el usuario. Ajustes:
   - Anillos mas cercanos: {60,90,120,150,180,210,240} en vez de
     {80,140,200,280,400}. Maximo 240px del source.
   - Mas anillos disponibles (7 vs 5) — fan-out gradual sin saltar
     bruscamente de 80 a 140.
   - Espaciado entre hermanos en arco usa cluster_min_dist=35 en
     vez de min_dist=60. Permite mas hijos por anillo dentro del
     arco de 45 grados (cap @ r=240 = 5 vs 3 antes).
   - Para 10-15 hijos tipicos los inner rings cubren todo dentro
     de 200px del source.

Build limpio. Tests WSL 102 / Windows 91 + 11 skipped.

Bonus: borrado /home/lucas/fn_registry/cpp/registry.db (vacio, 0
bytes, creado por algun binario con flag O_CREAT) — violacion de
db_locations.md (registry.db solo en raiz del repo). Era el motivo
de un test flaky de python_runtime_resolver.
2026-05-04 01:36:54 +02:00
egutierrez fdd169bc35 feat(0037): placement direccional 45 grados de orphans (away from centroide)
Antes los hijos del mismo anchor se distribuian en un anillo de 360
grados alrededor del padre. Cuando un enricher producia 10+ hijos,
se llenaban todas las direcciones y se pisaban nodos preexistentes.

Ahora los hijos se reparten en un abanico de 45 grados (pi/4) saliendo
del anchor en la direccion outward (vector anchor - centroide del resto
del grafo). Si solo hay 1 nodo placed o coincide con el anchor, default
a la derecha (0 rad). Capacidad por anillo restringida al arco
(arc_span * r / min_dist), con fallback de subida de radio en mismo
angulo si el slot ideal colisiona con un nodo no-orphan.

Solo afecta la pasada 2 (orphans con anchor). Pasadas 1 y 3 intactas.
build limpio, 102 pytest passed (WSL) + 91 passed/11 skipped (Windows).

Refs: issues/0037-directional-orphan-placement.md
2026-05-04 01:27:11 +02:00
egutierrez f6f53b60c3 feat(0036f): view menu accion 'Open NodeGroups for selected'
Anyade un item al menu View del framework via el nuevo callback
AppConfig.view_extras. El item:
- Esta enabled solo si la seleccion del viewport (o, en su defecto,
  el inspector) apunta a un nodo con type_ref Table o Group.
- Click → resuelve sql_id via entity_index_lookup, deriva
  NodeGroupsKind del type_ref y llama
  views_node_groups_open(g_app, sql_id, kind, ops_db). La API
  marca focus_request=true (cubierto por 0036c), de modo que la
  window emerge al frente si ya existia.
- Disabled → tooltip 'Select a Table or Group node first' (mostrado
  con AllowWhenDisabled).

Sin atajo de teclado (descartado por el usuario).
Sin submenu de windows abiertas (fase 2).

Refs: issues/0036f-view-menu-open-nodegroups.md
2026-05-04 01:12:58 +02:00
egutierrez f0d8a5ad04 feat(0036d): promote kind-aware (Group → clear group_id)
NodeGroups window kind=Group ahora expone un boton SmallButton(TI_ARROW_UP)
por fila que saca la entidad del grupo (group_id = NULL) y dispara
reload del grafo. kind=Table mantiene el comportamiento de issue 0011.

- entity_ops: nueva op `entity_clear_group_id(db, id)` idempotente. Si
  la columna group_id no existe (BD pre-0035a) retorna true como no-op.
  Falla solo si la entidad no existe o SQLite revienta.
- views.cpp: extra columna "promote" en kind=Group, tooltip header
  diferenciado por kind, boton conectado a app.want_clear_group_id_entity.
- main.cpp: handler que ejecuta entity_clear_group_id, marca windows
  como dirty, llama reload_after_mutation y loguea
  `[node_groups] promoted X out of group`.
- gx-cli: flag `node update --clear-group-id` (booleano) y exposicion
  MCP en inputSchema + MCP_DISPATCH defaults para que el agente Echo
  pueda promover via tool calls.
- tests: 3 nuevos CLI (clear, idempotente, combinable con --name) y
  4 MCP (defaults, schema, dispatch end-to-end, idempotente).

WSL: 102 passed (95 base + 7).
Windows: 91 passed, 11 skipped (84 base + 7).

Refs: issues/0036d-promote-kind-aware.md
2026-05-04 01:03:11 +02:00
egutierrez 8f91b4ed23 feat(0036c): doble click en Group abre NodeGroups; cleanup Table panel
Cambia el dispatch del doble click sobre nodos del viewport: si el tipo
es Group o Table, ahora abre/enfoca la NodeGroups window correspondiente
via views_node_groups_open(...). El branch de Group ya no carga el
panel Table generico con un filtro group_id (logica heredada de 0035d
que provocaba el bug de "tabla vacia").

Limpieza correlativa en views_table:
  - Eliminado el breadcrumb "Group: <name> (N)" + boton Clear filter.
  - Eliminado el filtro r.group_id != table_filter_group_id en
    build_visible y la restriccion de types_present.
  - Eliminado el reset on-close de los campos de filtro.

Eliminados los campos AppState::table_filter_group_id y
table_filter_group_name (audit: git grep table_filter_group_id devuelve
vacio fuera de issues/).

Render de NodeGroups ahora consume focus_request: llama
SetNextWindowFocus() antes de Begin y SetWindowFocus() dentro, asi la
window queda al frente tanto al crearse como al re-enfocarse.

El right-click "Open NodeGroups" del context menu sigue intacto
(want_toggle_nodegroups + node_groups_set_expanded). El doble click es
flujo paralelo nuevo.

Refs: issues/0036c-double-click-group-opens-nodegroups.md
2026-05-04 00:56:44 +02:00
egutierrez d6e13fddc3 feat(0036b): NodeGroups admite kind=Group + loader entities
NodeGroupsWindowState gana un discriminador `kind` (Table | Group) y
un flag `focus_request` (lo consumira 0036c). Por defecto Table, asi
que el flujo historico (DuckDB rows tras expand de un nodo Table) no
cambia.

kind=Group lee directamente operations.db consultando
`entities WHERE group_id = container_id` con columnas fijas
(id, name, type_ref, status, updated_at) ordenadas por updated_at DESC.
Los nuevos loaders viven en node_groups.cpp:

  - node_groups_count_for_group  -> SELECT count(*) ...
  - node_groups_page_for_group   -> SELECT id,name,type_ref,status,
                                     updated_at ... LIMIT ? OFFSET ?

Para columnas, opcion (A) del issue: pre-popular meta.columns con la
lista fija al abrir kind=Group, asi el render se mantiene generico.
NodeGroupsRow.values guarda los 5 campos en ese orden y row.id es la
key natural (= entity_id de la fila — al ser ya entidad, no hace falta
promocionarla).

Render en views.cpp ramifica por kind:

  - Table: layout original [id_col + columns + promoted] con doble
    click -> promote/focus.
  - Group: layout [columns fijas] sin promoted. Doble click sobre la
    fila ya pone want_focus_entity = id (los flujos posteriores 0036c-e
    afinan UX). Right click ofrece "Focus in Inspector".

main.cpp dispatcha por kind al refrescar paginas y, al cerrar via X,
solo llama a node_groups_set_expanded para kind=Table (Group no usa
ese flag).

views_node_groups_windows_sync se hace kind-aware: solo reconcilia
entries kind=Table contra el set de Tables expandidas; no toca las
entries kind=Group (las gestiona views_node_groups_open).

Nueva API publica:

  views_node_groups_open(app, container_id, kind, ops_db)

Crea o reusa la entry, setea focus_request=true y para kind=Group
pre-popula meta.columns + intenta leer `name` del Group para el
titulo. Sin caller todavia — la consume 0036c.

Tests:
  - tests/test_node_groups_loader.py (6 tests) verifica el contrato
    SQL via gx-cli. Nuevo subcomando `gx-cli group page <id>` espejea
    el loader C++ exactamente (mismo SQL); tambien expuesto como tool
    MCP `group_page` para que Echo pueda inspeccionar Groups.

Resultado:
  - WSL: 89 -> 95 passed
  - Windows: 78+11 -> 84+11 passed
  - Build C++ Windows limpio, sin warnings nuevos.
  - Regresion kind=Table: comportamiento identico (mismo render,
    mismo loader DuckDB).

Refs: issues/0036b-kind-discriminator-and-group-loader.md
2026-05-04 00:52:25 +02:00
egutierrez 810b564127 refactor(0036a): rename Table-expanded -> NodeGroups (paperwork)
Rename masivo sin cambio de comportamiento. Habilita 0036b-f que ya
asumen la nueva convencion.

Archivos:
- tableview.{cpp,h} -> node_groups.{cpp,h} (git mv para preservar history)
- CMakeLists.txt: tableview.cpp -> node_groups.cpp

Tipos:
- TableWindowState  -> NodeGroupsWindowState  (views.h)
- TableMetadata     -> NodeGroupsMeta         (node_groups.h)
- TablePageRow      -> NodeGroupsRow          (node_groups.h)

Campos AppState:
- table_windows         -> node_groups_windows
- table_node_counts     -> node_groups_counts
- toggle_expanded_id    -> toggle_nodegroups_id
- want_toggle_expanded  -> want_toggle_nodegroups

Funciones (window por contenedor — NO el panel generico Table):
- tableview_create / count / page / smoke_test / resolve_path /
  refresh_counts / list_columns / get_metadata / set_expanded /
  set_columns / promote_row / demote_row / ingest_file
  -> prefijo node_groups_*
- views_table_window         -> views_node_groups_window
- views_table_windows_sync   -> views_node_groups_windows_sync
- views_table_overlay        -> views_node_groups_overlay

Strings de UI:
- "Expand table" / "Collapse table" -> "Open NodeGroups" / "Close NodeGroups"
- Window title "<icon> <name>" -> "<icon> NodeGroups: <name>"
- Tooltip "(no expanded tables)" -> "(no open NodeGroups)"
- Logs [tableview_*] -> [node_groups_*]

Preservados intencionalmente (no son cambio de identificadores C++):
- CLI flag --test-tableview (cambiarlo seria cambio de behavior publico)
- Valor 'tableview' en columna entities.source (cambiarlo afectaria
  datos persistidos en BD)

NO tocado:
- Panel generico Table (views_table, panel_table, table_rows,
  table_show_all, table_search_buf, table_filter_*, table_col_filters,
  table_active_tab, TableRow, table_filter_group_*, etc.)
- issues/completed/* (historia)

Verificacion:
- Build C++ Linux + Windows: green sin warnings nuevos.
- pytest WSL: 89 passed.
- pytest Windows: 78 passed + 11 skipped.
- git grep audit: solo residuos en issues/ (historia) + CLI flag y
  source DB value preservados.

Refs: issues/0036a-rename-nodegroups.md
2026-05-04 00:43:16 +02:00
egutierrez 652ff6f02c fix(agent_jobs): queue dir desde GX_APP_DB, no GX_APP_DIR + logs verbosos
Bug derivado del fix anterior: gx-cli escribia ficheros JSON en
`$GX_APP_DIR/agent_jobs_queue/` (apuntando al repo fuente) mientras
main.cpp escaneaba `parent(g_layout_db_path)/agent_jobs_queue/`
(install Windows). Dos directorios distintos -> jobs huerfanos.

Echo reportaba "encolado" pero el worker nunca veia los ficheros.
La causa: chat.cpp setea GX_APP_DIR=<registry>/projects/osint_graph/
apps/graph_explorer y GX_APP_DB=<install>/local_files/projects/<slug>/
graph_explorer.db. Dos sitios. Solo APP_DB coincide con donde
graph_explorer.exe escanea (parent del .db).

Fix:

* gx-cli cmd_enricher_run: queue_dir = parent(GX_APP_DB) /
  agent_jobs_queue. Alineado con main.cpp.
* gx-cli: nuevo helper `_log(tag, msg)` que escribe a stderr Y a
  `<parent(app_db)>/gx-cli.log` para auditoria persistente. Cubre
  node_create, node_update, node_delete, rel_create, enricher_run.
* gx-cli mcp _mcp_log tambien persiste a gx-cli.log.
* main.cpp: log el queue scan dir una vez por sesion para detectar
  mismatches a futuro.
* .gitignore: agent_jobs_queue/ y gx-cli.log son runtime, no se
  commitean.

Tests:

* test_enricher_run_queue_dir_derives_from_app_db (regresion)
  configura GX_APP_DB en un dir distinto de GX_APP_DIR y verifica
  que el JSON aterriza junto a APP_DB.
* test_enricher_run_writes_log_to_gx_cli_log valida la auditoria.

WSL 81 / Windows 70 + 11 skipped.
2026-05-03 16:32:22 +02:00
egutierrez 3e7b3adc16 fix(agent_jobs): mover cola de SQLite a ficheros JSON (cross-9p safe)
Bug: Echo (gx-cli en WSL) recibia "disk I/O error" al INSERT en la
tabla `agent_jobs` de graph_explorer.db. Causa: graph_explorer.exe
mantiene esa BD abierta con journal_mode=WAL desde Windows, y SQLite
WAL exige mmap del .shm compartido entre procesos. Cuando un escritor
accede via /mnt/c (9p) y el otro nativo NTFS, ese mmap falla.

El proyecto ya habia resuelto este patron antes: el contador de
mutaciones (.mutations.marker) usa fichero plano en vez de SQL por
exactamente la misma razon. agent_jobs era la unica cola que se
quedo en SQLite — momento de aplicar el mismo fix.

Cambios:

* gx-cli cmd_enricher_run: en lugar de INSERT, escribe
  `<app_dir>/agent_jobs_queue/<req_id>.json` con el payload del job.
  Atomic write (tmp + rename, atomico tanto en NTFS como en 9p).
* main.cpp polling: en lugar de SELECT/DELETE sobre agent_jobs,
  escanea ese directorio cada frame, lee cada JSON via json_extract
  (sqlite3 in-memory, sin tocar archivos en disco), llama jobs_submit,
  y borra el fichero. Throttle a 8 jobs por frame igual que antes.
* main.cpp: anyade <filesystem> y <fstream>.
* tests/test_gx_cli.py: 5 tests nuevos en TestCliEnricherRun:
  - escribe fichero JSON con req_id como nombre
  - NO crea tabla agent_jobs en graph_explorer.db (regresion)
  - errores claros si enricher o nodo no existen
  - no quedan .tmp tras encolado exitoso

WSL 79 / Windows 68 + 11 skipped.
2026-05-03 16:23:18 +02:00
egutierrez b67da92e18 feat(0035d): doble click en Group abre tableview filtrado por group_id
- entity_ops: EntityRowSnapshot.group_id + SQL con COALESCE(group_id,'')
  + deteccion via PRAGMA para BDs viejas sin la columna.
- views.h: TableRow.group_id + AppState.table_filter_group_id /
  table_filter_group_name (RAM-only).
- main.cpp: dispatch en want_open_note — si type_ref == "Group", setea
  filtro de grupo + abre panel Table en vez de Note. Reset de search
  buf y col_filters al entrar al drill-in para que el usuario vea todo
  el contenido del grupo.
- views.cpp: build_visible compone group_id con search/tabs/col_filters
  (AND). types_present se reduce a tipos presentes en el grupo cuando
  hay drill-in activo. Header pintado en amarillo con TI_FOLDER +
  contador + boton "Clear group filter". Al cerrarse el panel se
  limpia el filtro automaticamente.

Tests: pytest 35 passed (WSL) / 24 passed + 11 skipped (Windows).

Refs: issues/0035d-tableview-drill-in.md
2026-05-03 14:57:20 +02:00
egutierrez d3da1416f3 feat(0035b): renderer oculta hijos de grupos colapsados + dedup aristas
- AppState anade `group_expanded` (unordered_map<string,bool>) en RAM,
  default vacio = todos los grupos colapsados al arranque. Sin
  persistencia entre sesiones (fase 1).
- `apply_group_filter(GraphData*, db_path, expanded)` consulta
  entities (id, group_id, type_ref) de operations.db, marca como
  ocultos los nodos cuyo group_id apunta a un grupo no expandido,
  compacta `g->nodes` y re-mapea indices de aristas.
- Aristas:
  * Cross-edge (un extremo oculto, otro fuera): se redirige el
    extremo oculto al nodo del grupo. Sin dedup (issue 0035 dec. 5).
  * Internas (ambos extremos en el mismo grupo colapsado): se ocultan.
  * Inter-grupo (ambos en grupos colapsados distintos): dedup por
    par no ordenado (group_a, group_b) + rel_type, una linea por par.
  * Orfanas (group_id apunta a un grupo no presente en grafo): el
    nodo se oculta y sus aristas se descartan.
- Centralizado: el filtro corre en `reload_graph()` cuando se le
  pasa `group_expanded`, y en `load_input()` tras el load inicial.
  Cubre las 4 rutas de carga del app (toolbar reload, mutaciones,
  inspector save, primera carga / switch project).
- Idempotente sobre un grafo ya filtrado y robusto frente a BDs sin
  columna `group_id` (schema antiguo) — no toca el grafo.

Smoke test manual con 3 BDs sintéticas:
- Grupo + 2 children + edges cruzadas/internas: nodes 5→3, edges
  4→3 (internal hidden, cross redirected).
- 2 grupos con 4 cross-edges entre ellos: edges 4→1 (dedup).
- group_id huerfano: nodo oculto + arista descartada.

Build clean en Windows. Tests verdes:
- WSL pytest: 32 passed.
- Windows pytest: 21 passed + 11 skipped.

Refs: issues/0035b-renderer-hides-grouped-children.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:48:17 +02:00
egutierrez 7a94160fd2 feat: catch-up de decisiones previas (Webpage→Url, anti-bot, UI 2-col, tests cross-platform)
Bloque de cambios revisados y validados con el usuario en sesiones
previas que no habian aterrizado en commits propios. Lista por tema:

* enrichers: web_search ahora usa lite.duckduckgo.com como endpoint
  primario (mas tolerante con bot detection desde IP residencial),
  con fallback al endpoint html. Detecta pagina captcha y emite
  error claro si ambos fallan. Anyade _DDGLiteParser para el formato
  lite + auto-pick de parser por contenido.

* enrichers: tipo Webpage unificado en Url (campos de cuerpo
  cacheado viven en metadata del Url). Manifests actualizados
  (applies_to: [Url]). fetch_webpage ya no convierte Url->Webpage.

* enrichers/manifest: campo `params` parseado a EnricherSpec.params
  (name, type, default_value, description). UI puede renderizar
  dialog de configuracion.

* jobs: fix de path conversion para Python embebido nativo Windows
  (no convertir a /mnt/c/... cuando el subproceso es Windows-native;
  solo cuando es bash o python via WSL).

* main.cpp: ventana ImGui (no modal) "Run enricher" con layout
  2-col (label izq, input der). Inserta job con JSON tipado. Layout
  clustering apretado: hijos del mismo anchor en un solo anillo
  alrededor del padre, sin desperdigar por anillos crecientes.

* views: inspector con layout 2-col via BeginTable (Identity,
  Schema fields, Extras). Description full-width debajo de su label.

* tests: portable conftest (auto-detecta REGISTRY_ROOT, PYTHON_BIN,
  ENRICHERS_DIR para WSL y Windows portable). _runner.py trampoline
  inyecta stub via sys.path porque embedded Python ignora PYTHONPATH.
  Tests bash-only (vendor_script, freeze, dispatcher bash, resolver
  Linux-binary) skipean en Windows. Tests existentes adaptados a
  Webpage->Url.

Resultado actual: 32 passed WSL, 21 passed + 11 skipped Windows.
2026-05-03 14:41:28 +02:00
egutierrez fc4f0824da feat(0035a): tipo Group + columna group_id en entities
Plumbing para issue 0035 — agrupacion de resultados de enrichers
cuando exceden umbral. Sin cambios visibles para el usuario todavia.

- Migracion idempotente: ALTER TABLE entities ADD COLUMN group_id si
  no existe (detectado via PRAGMA table_info). Se ejecuta al abrir
  el proyecto en switch_to_project y en el bootstrap inicial.
- Tipo Group en examples/types.yaml (template) y en el types.yaml
  del proyecto default activo en Windows.
- shape=square (regla en types_registry.cpp extendida a Group),
  color=#94A3B8, icon=ti-stack-2.
- Fields: name (req), count (int), enricher (string), batch_id (string).

Refs: issues/0035a-group-type-and-schema.md
2026-05-03 14:23:23 +02:00
egutierrez 8623732d6d feat(graph_explorer): adopta layout assets/ via fn::asset_path
Junto con el cambio del framework (commit 81d8a7c9), graph_explorer
ahora resuelve enrichers/, runtime Python y gx-cli desde
<exe_dir>/assets/ con fallback a las rutas dev legacy.

- main.cpp: enrichers_dir busca primero <exe_dir>/assets/enrichers/
  (deploy con /compile). Fallback a <app_dir>/enrichers/ del repo
  cuando se ejecuta desde build/ (modo dev).
- jobs.cpp::resolve_python_runtime: incluye
  <exe_dir>/assets/runtime/python/{python.exe|bin/python3} como
  primera opcion de la cadena de fallback. La opcion legacy sin
  assets/ queda como segundo intento.
- chat.cpp: gxcli_path busca <exe_dir>/assets/gx-cli{.exe} con
  fallback a <app_dir>/gx-cli para modo dev.

Tests: 32/32 verde. Build Linux + Windows OK. Deploy fresco a
Desktop con todas las 6 apps confirma layout limpio:
  <app>.exe + (duckdb.dll si aplica) + assets/ + local_files/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:50:44 +02:00
egutierrez 7a055809c2 feat(graph_explorer): adopta convencion local_files/
Sustituye paths hardcodeados (graph_explorer.db, graph_explorer.ini,
projects/) por resolutores que apuntan a <exe_dir>/local_files/.

- project_manager: k_projects_dir y k_settings_file pasan a ser
  helpers projects_root() / settings_path() que llaman a
  fn::local_path internamente. Layout en disco documentado en el
  comentario de cabecera del .h.
- main.cpp: el modo legacy y el fallback de jobs_init usan
  fn::local_path('graph_explorer.db') en lugar de relativo al cwd.

Junto al cambio del framework (commit f102aba9), graph_explorer
se distribuye con su carpeta limpia: solo .exe + duckdb.dll +
TTFs + enrichers/ + runtime/. Todo el estado del usuario vive
en local_files/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:33:08 +02:00
egutierrez 0d2450bac5 feat(chat): panel Echo + gx-cli MCP server con tools tipadas
Anade panel "Echo" — copiloto OSINT que invoca claude -p con un MCP
server propio (gx-cli) exponiendo el grafo como tools tipadas:
info, node_*, rel_*, table_*, enricher_*, query.

Cambios:
- chat.cpp/h: panel UI dockeable con history, raw stream-json toggle,
  spawn de claude -p con system prompt OSINT, ChatMessage con USER/
  ASSISTANT/TOOL_USE/TOOL_RESULT/SYSTEM/ERROR_MSG, escritura de
  mcp.json con paths Linux para WSL en Windows.
- gx-cli: binario MCP standalone que valida cada tool, abre
  operations.db en RW, escribe agent_mutations counter para que el
  viewport detecte cambios en vivo.
- CMakeLists.txt: anade chat.cpp al target.
- views.h: panel_chat boolean en AppState.
- main.cpp: integracion del panel Chat (rename a Echo en menubar +
  init), refresh de contexto al cambiar operations.db, drain de cola
  agent_jobs tras enricher_run.

Mensajes del panel renderizan con fn_ui::selectable_text_wrapped_force
(wrap forzado + seleccion) para que URLs/JSON largos no se clippeen
y permitan copy/paste.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:10:01 +02:00
egutierrez 4281f3ccb2 fix(jobs): autodetectar distro WSL + normalizar separadores UNC (issue 0026)
El usuario reportaba "no enrichers for url" en Windows. Tres bugs:

1. resolve_registry_root tenia el fallback hardcoded a "Ubuntu" pero la
   distro real era "Ubuntu-22.04". Reemplazado por detect_wsl_distro()
   que sondea las distros comunes (Ubuntu, Ubuntu-24.04, Ubuntu-22.04,
   Ubuntu-20.04, Debian, kali-linux, Fedora, openSUSE-Tumbleweed) y se
   queda con la primera cuyo UNC tenga registry.db.
2. enrichers_load construia paths con mixed separators
   ("\\\\wsl.localhost\\Ubuntu-22.04\\...\\enrichers/foo/manifest.yaml")
   que confunden a opendir de MinGW. Ahora normaliza todo a backslashes
   en Windows antes de opendir + concatena con el separador nativo.
3. El menu "Run enricher" decia simplemente "(no enrichers para tipo X)"
   sin distinguir si era 0/N (no se carga ninguno) o N>0/M (existen pero
   ninguno aplica). Ahora muestra "(no enrichers cargados — revisa
   FN_REGISTRY_ROOT)" vs "(0/4 enrichers para tipo 'url')".

Si el usuario tiene una distro con nombre raro, sigue pudiendo setear
FN_REGISTRY_ROOT explicitamente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:27:28 +02:00
egutierrez c3ce9956f7 feat(jobs): implementacion Win32 — wsl.exe + path translation (issue 0026)
Sustituye el stub Windows por la implementacion real:

C++:
- Bloque #ifdef _WIN32 con CreateProcessW + 3 anonymous pipes
  (CreatePipe + SetHandleInformation), STARTF_USESTDHANDLES,
  CREATE_NO_WINDOW, ReadFile/WriteFile, WaitForSingleObject con polling
  para soportar cancelacion via TerminateProcess.
- Helper to_wsl_path: convierte paths Windows a WSL antes de mandarlos
  al subprocess. Soporta:
    * "C:\\..."                   -> "/mnt/c/..."
    * "\\\\wsl.localhost\\<distro>\\..." -> "/..."
    * "\\\\wsl$\\<distro>\\..."         -> "/..."
    * "/..."                      -> tal cual
  En POSIX la funcion es no-op.
- build_stdin_json siempre normaliza ops_db_path/app_dir/cache_dir/
  registry_root a paths WSL — el run.py corre dentro de WSL y solo
  entiende paths /home, /mnt, etc.
- Subprocess invocacion: `wsl.exe --cd <root_wsl> -- <python_wsl> <run_wsl>`.
  Asume que el usuario tiene WSL instalado y la distro Ubuntu (o ajusta
  FN_REGISTRY_ROOT al UNC adecuado).
- kill_proc unificado: TerminateProcess en Win32, kill(SIGTERM) en POSIX.
- JobControl con HANDLE+pid en Win32, pid_t en POSIX.

main.cpp:
- resolve_registry_root con fallback Windows: si FN_REGISTRY_ROOT env
  no esta y getcwd no encuentra registry.db (caso del .exe en Desktop),
  usa "\\\\wsl.localhost\\Ubuntu\\home\\lucas\\fn_registry". El usuario
  cambia el UNC via env var si su distro tiene otro nombre.

Build:
- cpp/build/windows/apps/graph_explorer/graph_explorer.exe linkea limpio
  contra MinGW; solo dependencias windows.h estandar (kernel32, etc.).
- Linux smoke test sigue detectando los 4 enrichers tras la
  refactorizacion compartida.

Notas operativas para el usuario Windows:
- Ejecutar el .exe desde C:\\Users\\lucas\\Desktop\\apps\\graph_explorer\\
  (doble clic). El primer arranque tarda ~1 s mas por cold-start de wsl.exe.
- Si la distro no es Ubuntu, setear FN_REGISTRY_ROOT con el UNC correcto
  (ej. "\\\\wsl.localhost\\Debian\\home\\lucas\\fn_registry").
- Cancelar un job en Windows usa TerminateProcess (mas brutal que SIGTERM
  pero los run.py no tienen estado critico — sqlite3 rollback automatico
  por la transaccion implicita).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:49:36 +02:00
egutierrez 012e2e97a6 fix(layout): layout estable al recargar (issue 0031)
Antes: cada reload disparado por enrichers (dirty_counter) ejecutaba
graph_viewport_fit (recentraba camara), recargaba desde SQL con todos
los nodos en (0,0), aplicaba layout_circular si todo estaba en cero, y
los huerfanos quedaban apilados sobre el origen. Si physics estaba ON,
las fuerzas dispersaban todo el grafo violentamente.

Ahora:
- Auto-save de posiciones antes de cada reload — preserva lo que el
  usuario ve en pantalla sin pulsar "Save layout".
- No graph_viewport_fit en reloads (solo en primera carga via
  load_input(first_load=true)). La camara permanece donde estaba.
- No layout_circular en reloads (mismo guard via first_load).
- Halo placement: nodos huerfanos (en (0,0) tras layout_store_load)
  se colocan junto a su primer vecino con coordenadas conocidas,
  buscando slot angular libre en radios crecientes (80,140,200,280,400)
  con jitter deterministico por user_data. Si no hay vecinos
  colocados, se aparcan en columna lateral fuera del bbox.
- Anti-overlap garantizado a min_dist=60 px entre centros.
- Physics siempre OFF tras reload — el usuario las activa
  explicitamente.
- Auto-save tambien al inicio de reload_after_mutation (mutaciones
  manuales add/delete/duplicate/change_type) por consistencia.
- Refresca entity_index tras reload (los nuevos nodos creados por
  enrichers tienen user_data nuevos que el indice anterior no conoce).

Tests visuales: compila limpio, jobs_init continua detectando
enrichers, smoke test del binario OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:39:59 +02:00
egutierrez 6df04652d8 feat(jobs): sistema de jobs asincronos + panel UI (issue 0026)
Infra para correr enrichers en background mientras la app sigue interactiva.

C++:
- jobs.{h,cpp}: tabla jobs en graph_explorer.db, JobRunner con N=2 std::thread
  workers, fork+exec POSIX con pipes, parser de PROGRESS:<float> <stage> en
  stderr, captura de stdout JSON, persistencia + dirty_counter.
- enrichers.{h,cpp}: scanner de enrichers/<id>/manifest.yaml, parser YAML
  minimo (id/name/description/applies_to), filtro por tipo de nodo.
- views_jobs.cpp: panel "Jobs" dockeable con tabla (status/enricher/target/
  progress/time), filtro all/active/done/errors, cancelar/borrar inline.

Wiring:
- main.cpp: resolve_registry_root() (FN_REGISTRY_ROOT env o subir desde cwd
  buscando registry.db), jobs_init/enrichers_load antes de fn::run_app,
  jobs_shutdown al cerrar, dirty_counter -> want_reload, jobs_set_ops_db al
  cambiar de proyecto.
- main.cpp:render_context_menu: menu "Run enricher" sustituye placeholder
  con submenu filtrado por type_ref via enrichers_for_type. Submit abre
  panel Jobs auto.
- views.h: AppState::panel_jobs flag + decl views_jobs().
- CMakeLists.txt: anade jobs.cpp + enrichers.cpp + views_jobs.cpp y enlaza
  Threads::Threads.

Wire protocol enricher (subprocess Python):
- stdin:  JSON con node_id, metadata, ops_db_path, app_dir, cache_dir,
          registry_root, params.
- stderr: PROGRESS:<float> <stage> + LOG lineas libres.
- stdout: JSON resumen al final.
- exit 0 = ok, !=0 = error con stderr capturado en panel Jobs.

El run.py escribe directamente al operations.db (sqlite3 stdlib) — C++ solo
orquesta, no parsea entities/relations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:24:37 +02:00
egutierrez e6719a5ae0 fix(tableview): paths normalizados (Windows) + error visible en ventana
Hipotesis del bug 'tras promover, la tabla expandida queda a 0 filas':
en Windows std::filesystem::path::string() devuelve la ruta con
backslashes ('C:\\Users\\...\\operations.db'). Al embebirla en
'ATTACH ''<path>'' AS ops' DuckDB la interpretaba con quirks segun
version, fallaba el ATTACH (silent), pero ademas el siguiente
duckdb_open con paths mixtos podria no abrir el .duckdb correcto.

Cambios:
- tableview_resolve_path normaliza '\\' -> '/' (DuckDB acepta ambos
  para duckdb_open, pero forzamos '/' para evitar ambiguedad en SQL).
- ATTACH normaliza ops_db tambien.
- TableWindowState.last_error: cuando count o page fallan, se setea
  con el path/tabla involucrada y se muestra en rojo en la cabecera
  de la ventana. Asi el bug es visible sin abrir consola.
- tableview_page log incluye la SQL completa cuando falla — facil
  diagnosticar via stderr en linux.
2026-05-01 17:02:22 +02:00
egutierrez 87a554da84 feat(toolbar): Layout dropdown + Physics toggle, default fixed/paused
Cambios de UX en la toolbar y arranque:

- Boton 'Layout: <name>' que abre popup con la lista de layouts (force,
  grid, circular, radial, hierarchical, fixed) + 'Reset positions
  (unpin + restart)' + 'Save current layout'. Reemplaza el combo
  pequeno + los botones Save/Reset que estaban dispersos.

- Boton 'Physics: ON/OFF' (Player Play/Pause) toggle visible que
  reemplaza el checkbox 'Run layout'. Variant Primary cuando ON,
  Subtle cuando OFF.

- Default: layout_mode = 5 (fixed) y layout_running = false. Asi al
  abrir un proyecto los nodos respetan posiciones guardadas y no se
  mueven solos. El usuario activa fisicas con el boton Physics y/o
  cambia el layout desde el dropdown si quiere.

Reset layout (boton dentro del popup Layout) sigue activando physics
para que el grafo se reasiente; es el flujo natural del 'Reset'.
2026-05-01 16:54:27 +02:00
egutierrez 60cbb43966 fix(main): reaplica types.yaml + atlas tras cualquier mutacion
reload_after_mutation reconstruye g_graph.types[] con defaults via
reload_graph, pero NO reaplica el types.yaml ni reconstruye el icon
atlas. Resultado: cualquier mutacion (add/delete/duplicate/change_type/
promote/demote/import) hacia que los tipos perdiesen shape/color/icon
y todos los nodos volvieran a renderizarse como circulos grises.

Caso reproducible: doble-click en fila de tabla expandida -> promote
-> reload -> el nodo Table dejaba de ser cuadrado y se renderizaba
como circulo.

Fix: tras reload_graph + entity_index_build, si parsed_types tiene
contenido, reaplicar types.yaml y reconstruir el atlas con un
graph_icons_destroy + build_icon_atlas + g_atlas_bound=false +
g_gpu_dirty=true para que el viewport rebincie en el siguiente frame.
2026-05-01 16:45:32 +02:00
egutierrez b798454f35 feat(table-node): edge CONTAINS_ROW al promover + tabla cuadrada real
Tres ajustes derivados de feedback en uso:

1. tableview_promote_row recibe ahora `table_entity_id` y, si no es
   nulo, inserta una relacion 'CONTAINS_ROW' (id estable, INSERT OR
   IGNORE) entre la tabla origen y la entidad promovida. El viewport
   pinta la arista de pertenencia automaticamente sin codigo extra.

2. apply_types_yaml fija default_size = 32 px (world) para tipos
   Table junto al SHAPE_SQUARE ya existente. La GPU pinta el cuadrado
   real; antes era invisible bajo el overlay rectangular.

3. views_table_overlay adelgaza al rol que le toca: solo dibuja un
   contador discreto "<N> rows" debajo del cuadrado (texto pequeno
   con bg semitransparente). El cuadrado en si lo pinta el GPU.

Defensiva: views_table_windows_sync marca page_dirty=true en TODAS las
windows live tras cada sync para que el flag promoted se refresque
inmediatamente despues de promote/demote/import.
2026-05-01 14:18:26 +02:00
egutierrez 6c1be87a2d feat(main): wire 0011 — context menu + triggers + sync de windows
- Context menu del viewport detecta type_ref='Table' y anade Expand/
  Collapse table. Toggle escribe metadata.expanded en BD y resincroniza
  table_windows.
- Triggers want_promote_row -> tableview_promote_row + reload + focus
  inspector con la entidad recien creada.
- want_demote_entity -> tableview_demote_row + reload.
- want_focus_entity: resuelve entity_id -> node_idx via FNV1a, centra
  camara, abre inspector.
- want_import -> tableview_ingest_file + tableview_create + reload.
- Loop por table_windows page_dirty -> tableview_count + (si columns
  vacios) descubre+persiste columnas + tableview_page.
- Cierre via X de ventana detectado leyendo open=false; bajamos
  expanded en BD y borramos del mapa.
- Sync de table_windows tras load_input y reload_after_mutation.
- views_table_window + views_import_dataset_modal llamados en render().
2026-05-01 01:53:12 +02:00
egutierrez 082008bc00 feat(table-node): DuckDB foundation + render colapsado (issue 0010)
- tableview.{h,cpp}: capa C sobre DuckDB v1.1.3.
  * tableview_smoke_test (SELECT 42).
  * tableview_count (con sql_filter opcional).
  * tableview_page (LEFT JOIN sobre ops.entities via ATTACH para flag promoted).
  * tableview_create (inserta entidad type_ref='Table' con metadata pointer).
  * tableview_refresh_counts (lee Table entities, count cada DuckDB y cachea
    por user_data hash).
  * tableview_resolve_path (rel a dirname(ops_db) o absoluto).
- AppState::table_node_counts cache, refrescado tras load_input y mutaciones.
- views_table_overlay: rectangulo redondeado overlay ("Table  N") encima
  de cada nodo type_ref='Table'. Sigue camara via cam_x/cam_y/zoom.
- main.cpp:
  * --test-duckdb <path> smoke (SELECT 42).
  * --test-tableview <path> bulk test (1M rows count + page offset).
  * Refresh de counts tras load + reload_after_mutation.
  * Llamada a views_table_overlay despues de graph_labels_draw.
- CMakeLists.txt: link DuckDB::DuckDB + duckdb_copy_runtime.

Smoke tests:
- 1M rows count + page(offset=500k, limit=10) en 0.65 s end-to-end.
- Operations.db con un nodo Table apuntando a duckdb 1M filas: refresh
  reporta correctamente "1 tables, 1000000 total rows".
2026-05-01 01:24:43 +02:00
egutierrez 84afa4ce70 feat(table): vista tabla por tipo de entidad (issue 0004)
- entity_ops: entity_list_rows (bulk pull id/name/type_ref/status/updated_at).
- AppState::TableRow + cache + filtros (search substring + show_all toggle).
- views_table: tabs por type_ref (alfabetico) o tabla unica con todos los
  tipos. ImGui::BeginTable con sort + clipper para >10k filas. Click en
  Selectable selecciona el nodo en el viewport (clear + add via
  graph_viewport_*).
- views_table_refresh_indices: degree + node_idx por user_data hash.
- main.cpp: panel "Table" en g_panels; cache build tras load_input y
  reload_after_mutation.
2026-05-01 01:05:03 +02:00
egutierrez f80348d604 feat(types): Type Editor panel — CRUD de tipos en vivo (issue 0007)
- views_type_editor: panel "Types" con tabs Entities/Relations.
  Entities: name, color picker, shape combo, icon (ti-* + cp preview),
  principal_field combo, tabla de Fields (string/int/float/bool/date/url/enum)
  con required y enum values CSV; up/down/X por fila.
  Relations: name, color, style.
  Footer Save / Reload from disk + indicador dirty + error inline.
- views_type_editor_delete_modal: confirm con conteo de entidades en uso.
- types_registry: shape_name() + shape: emit en types_save_yaml para
  round-trip estable de la cosmetica editada en UI.
- main.cpp: panel "Types" en g_panels; init types_draft tras load_input;
  want_types_save -> save + apply_types_yaml + rebuild atlas + bind +
  refresh inspector caches; want_types_reload simetrico; conteo de
  uso desde operations.db cuando se abre el modal de delete.
2026-05-01 00:42:30 +02:00
egutierrez 54aba71bf5 feat(filter): tag chips + FTS5 search en toolbar (issue 0009)
- entity_ops: entity_search_fts (bm25, prefix tokens) + entity_list_by_tags (AND).
- AppState: filter_query_buf, filter_tags, filter_mode (highlight/hide), hits cache.
- views_filter_apply: combina tipos visibles + match-set (FTS query AND tags),
  highlight = color_override con alpha=0x40, hide = clear NF_VISIBLE.
- toolbar: input search + dropdown (max 20, click centra y selecciona),
  chips de tags con boton X, input para anadir tag, combo Highlight/Hide,
  Clear filter.
- Inspector: right-click sobre tag chip lo anade al filtro.
- main.cpp: reapply en cada frame si filter_dirty; cam_x/y al focus_target.
2026-05-01 00:23:26 +02:00
egutierrez 6560e358bf feat(main): wire Inspector — load on selection, Save/Discard, refresh caches
Issue 0008:
- types.yaml: stash ParsedTypes en g_app.parsed_types tras parsear (lo
  consume el Inspector para resolver schemas por type_ref).
- load_input: tras load, llama views_inspector_clear_draft + _refresh_caches.
- switch_to_project: limpia draft y parsed_types antes de cargar nuevo.
- render(): seleccion → load_draft (si single sel y no dirty), Save →
  entity_update + reload_graph + relocaliza node_idx por sql_id +
  re-load draft + reselecciona en viewport, Discard → re-load draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:13:08 +02:00
egutierrez fb7538088b feat(types): semilla con fields para 11 tipos + CLI --test-types-yaml
- examples/types.yaml: principal_field + fields para Person, Email,
  Domain, Phone, Org, IBAN, Account, Document, Address, Url, Table.
  44 fields totales. Documentacion del formato en cabecera.
- project_manager.cpp: seed con fields para los tipos basicos (fallback
  cuando no se encuentra examples/types.yaml).
- main.cpp:
  - Log de carga incluye conteo de schemas y total de fields.
  - --test-types-yaml <path>: smoke test que carga, serializa a temp y
    recarga. Compara entidades/relaciones/fields field-a-field. Salida
    PASS/FAIL con exit code 0/1. Permite verificar round-trip sin
    framework de tests.

Verificado: examples/types.yaml round-trip estable (11 entities, 44
fields, 6 relations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:00:53 +02:00
egutierrez f0148ac368 fix(viz): forzar circulo a todos los nodos salvo Table; tapar energia inicial
- types_registry.cpp::apply_types_yaml: tras aplicar el yaml, sobreescribe
  shape de cada tipo: 'Table' → SHAPE_SQUARE, todo lo demas → SHAPE_CIRCLE.
  Convencion fija — ediciones futuras del Type Editor (issue 0007) o del
  yaml no rompen la regla.
- examples/types.yaml + project_manager.cpp seed: quitar campo `shape`,
  añadir tipo Table (cuadrado) y relacion CONTAINS_ROW (preview de 0010).
- main.cpp run_force_step: damping=0.7, max_velocity=8 explicitos para
  evitar que el grafo "explote" al cargar grafos pequenos.
- AppState repulsion: 1500 → 800 (lo mismo, aplicado al force layout).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:53:21 +02:00
egutierrez d6f7318c24 feat(projects): integrate project switcher in app shell + Inspector menu
main.cpp:
- Forward decl + switch_to_project: cierra layout_store, libera grafo,
  aplica nuevos paths, vuelve a cargar.
- apply_project_paths: deriva operations.db/types.yaml/graph_explorer.db
  del slug y los expone a g_app.active_project.
- main: arg --project <slug>; modo legacy si --input/positional dado;
  modo proyecto si no — migra layout legacy, decide target via
  arg/last_active/'default', crea si no existe, abre BDs y carga.
- render(): handler want_switch_project + monta views_new_project_modal.

views.h: AppState gana active_project, want_switch_project,
switch_project_target, show_new_project_modal, new_project_buf,
new_project_error, project_list_cache, project_recent_cache.

views.cpp:
- Toolbar: boton 'Project: <slug>' con popup (New/Recent/Open/Reveal).
  Refresca caches al abrir el menu.
- views_new_project_modal: input slug + validacion + creacion + switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:46:47 +02:00
egutierrez adde3026ea fix: docking gaps + hover radius + node spread + markdown notes
Bug fixes
- ImGui ID conflict en menu Change type: dedup tipos del grafo +
  defaults; PushID/PopID por entrada.
- Dockspace ya no tapa la toolbar: se posiciona 44 px por debajo, asi
  las ventanas dockeadas al borde superior quedan bajo la barra de
  filtros, no detras.
- Hover radius proporcional al tamaño visual del nodo: query espacial
  amplio (24/zoom) + filtro fino por (radio_visual + 2 px) / zoom. El
  tooltip solo se dispara si el raton esta efectivamente sobre el nodo.

Layout
- Default layout = grid (en vez de force) para que los grafos cargados
  se distribuyan ordenadamente al abrir.
- Boton "Reset layout" en la toolbar: limpia NF_PINNED en todos los
  nodos, resetea velocidades y reaplica el layout activo.
- Nodos recien creados (add_node, duplicate) caen en un anillo poisson
  alrededor del centro de la vista, no en el origen. Posicion
  determinista por user_data para que el mismo nodo no salte entre
  reloads.

Notes (markdown)
- Panel "Note" (dockeable) abierto con doble click sobre un nodo.
- entity_get_notes / entity_set_notes en entity_ops sobre la columna
  `notes` de operations.db (ya existente en el schema).
- Ctrl+S guarda. Cabecera muestra entity, type, id.
2026-04-30 23:11:48 +02:00
egutierrez a36530bb6f feat: docking host + add-node toolbar + node context menu
- Dockspace host (PassthruCentralNode) bajo la toolbar para que las
  ventanas Viewport/Legend/Inspector/Stats puedan dockearse dentro de la
  app principal.
- Toolbar: input "Add node" con auto-deteccion de tipo (text/email/
  ip_address/url/domain/phone). Insert en operations.db + reload.
- Context menu (right-click sobre nodo): Change type, Duplicate, Delete,
  submenu "Run enricher" (placeholder hasta issues 0001-0003).
- Inspector: vecinos ahora muestran etiqueta de relacion ("-> employs",
  "<- owns") usando rel_types[].name como label de arista.
- Default relation label k_default_relation_name="RELATED_TO" para
  relaciones creadas sin nombre semantico explicito.
- Indice EntityIndex (FNV1a hash -> sql id) reconstruido tras cada load
  para resolver mutaciones desde el grafo en memoria.

Issues planteadas para iteraciones siguientes:
- 0001: chat con Claude sobre el grafo (HTTP + tool-use)
- 0002: enricher GLiNER+GLiREL desde nodo texto
- 0003: enricher web (fetch URL/dominio + extract text)
- 0004: vista tabla por tipo de entidad
2026-04-30 22:55:30 +02:00
egutierrez b767b5b85e feat: graph_explorer app — agnostic operations.db viewer (issue 0049k)
App C++ ImGui que abre cualquier operations.db del registry y lo visualiza
como grafo con shapes/iconos/layouts/filtros/labels.

Composicion del registry:
- viz/graph_renderer + graph_force_layout(_gpu) + graph_layouts +
  graph_viewport + graph_labels + graph_icons + graph_sources
- core: toolbar, modal_dialog, select, text_input, tree_view, page_header,
  fullscreen_window, button, badge, empty_state

Capas:
- data.{h,cpp}    — dispatcher GraphLoadFn (operations hoy; json/graphml manana).
- types_registry.{h,cpp} — parser YAML minimal + tabler_codepoint_by_name +
  apply_types_yaml + IconAtlas builder.
- views.{h,cpp}   — Toolbar, Legend, Inspector, Stats, modal Filters/Open.
- layout_store.{h,cpp} — graph_explorer.db SQLite con tabla layouts(graph_hash,
  node_id, x, y, pinned, updated_at). UPSERT por nodo.
- main.cpp        — CLI (--input/--types/--layout) + fn::run_app + bucle
  force layout (CPU/GPU toggle) + render con 3 columnas (Legend / Viewport /
  Inspector+Stats).

examples/types.yaml: 10 entidades OSINT (Person/Email/Domain/Phone/Org/IBAN/
Account/Document/Address/Url) + 5 relaciones (owns/knows/located_in/
transfers_to/member_of) con shapes Tabler reales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:13:59 +02:00