| src | ||
| .gitignore | ||
| eslint.config.mjs | ||
| jsconfig.json | ||
| next.config.mjs | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
Migración en curso desde el proyecto con cliente pesado hacia arquitectura preparada para consumir datos desde una API, separando responsabilidades. De momento los datos están en formato JSON local en src/data.
El objetivo de esta primera iteración es levantar el proyecto pero adaptado a los nuevos requerimientos.
Stack: Next.js 16 - React 19 - Cytoscape.js
- Antes, entendiendo lo que hay
- Template inicial + instalación de dependencias
- Creación de DataService - acceso a datos local
- Creación de useGraphData - lógica de estado
- Testeando consumo de datos locales(temporal)
- Migración de componentes
en un mundo ideal >> *dataservice local -- front funcional -- api real -- solo cambiar dataservice
Antes, entendiendo lo que hay...
Primera aproximación a entender los datos:
Los nodos están en data/nodes.js: representan organismos, áreas y sistemas informáticos
Las relaciones están en data/edges.js: representan relaciones funcionales/de intercambio entre nodos.
Los grupos están en data/groups.js: representan vistas lógicas o recortes temáticos del grafo (no relaciones)
Estructura de un nodo
{
data: {
id: 'mec',
label: 'Ministerio de Economía',
dependency: 'mec-alt',
sublabel: 'Economía'
},
classes: 'org'
}
id: id único del nodo - se usa en edges.sourse/target o groups.nodeIds
label: nombre - lo que se ve en la ui
dependency: dato opcional - id del nodo padre - es estructura
sublabel: texto adicional para los nodos nivel2 para describir al organismo que pertenecen (opcional)
classes: tipo o clase del nodo
- org: organismo
- nivel1 : subsecretaria
- nivel2: dirección/área
- sistema: sistema informático
Estructura de una arista/edge
{
data: {
source: 'mec-info', // id del nodo origen
target: 'sulh' // id del nodo destino
},
classes: 'manual' // tipo de conexionn - opcional
}
source y target: id del nodo origen y id del nodo destino
"el sistema/área con id mec-info se vincula con sulh"
classes: puede ser manual o no ser especificada, asumimos que si no es manual es automática * (ej.: un archivo para se pasa manualmente de uno a otro)*
{ data: {
source: 'mec-eclab',
target: 'sulh'
}
}
{ data: {
source: 'sulh',
target: 'siape'
}
}
{ data: {
source: 'sulh',
target: 'sigaf'
}
}
- Economía Política Laboral usa SULH
- SULH se relaciona con SIAPE
- SULH hace algo en SIGAF
En este archivo data/edges.js están las relaciones de Interoperabilidad entre sistemas y están definidas manualmente
{ data: { source: 'mec-info', target: 'sulh' } }
y hay otro tipo de relación:
Relaciones de dependencia jerárquica
ANTES, estas relaciones se generaban automáticamente en un archivo en src/data/index.js (del proyecto original)
Entonces, ahí mismo se hacía el procesamiento de aristas: las dependencyEdges se calculaban en tiempo de importación
Se exportaban de manera centralizada los elementos (nodes y edges) y los grupos
src/data/index.js:
import nodes from './nodes';
import systemEdges from './edges';
import groups from './groups';
const dependencyEdges = nodes
.filter(node => node.data.dependency)
.map(node => ({
data: {
source: node.data.id,
target: node.data.dependency
}
}))
export default {
groups,
elements: {
nodes,
edges: systemEdges.concat(dependencyEdges)
}
};
- lógica que se ejecuta al importar
- no hay control sobre cuando se procesan
AHORA se generan automáticamente ahora desde el DataService de la misma manera, a partir de los nodos que tienen el atributo dependency:
src/services/DataService.js
this.dependencyEdges = nodes
.filter(node => node.data.dependency) //solo nodos con padre
.map(node => ({
data: {
source: node.data.id, //nodo hijo
target: node.data.dependency //nodo padre
}
}));
/* combinar aristas de sistema (interoperabilidad) con aristas de dependencia, esto se hace una sola vez, no cada vez que pedimos edges */
this.allEdges = systemEdges.concat(this.dependencyEdges);
Ejemplo:
mec-info ---> mec-alt (Informática depende de Subsecretaría Administrativa)
mec-alt ---> mec (Subsecretaría depende del Ministerio)
Estas representan la estructura organizacional (org ---> nivel1 ---> nivel2)
AHORA: index.js se fue
Entonces, resumiendo:
- La relación jerárquica se hace con
dependency - La relación de interoperabilidad se hace con
edges
org (mec)
|──> nivel1 (mec-alt)
|──> nivel2 (mec-info) ──────> sistema (sulh)
[edge]
1. Template inicial + instalación de dependencias
Inicialmente tengo un un template emulando la estructura original.
Resumen de lo primero que hice: modifique los archivos .js -> pasan a .jsx , agregando App Router de next.js para adaptar la estructura a Next y sumando services que tiene un dataservice para consumir los datos localmente y mas cosas (solo estructura) para el futuro con el backend + hook para manejar los datos del grafo.
- src/
+ ├── app/ # app router de next.js ppal
+ │ ├── api/ # endpoints imaginarios
+ │ │ ├── nodes/
+ │ │ │ └── route.js # api para nodos
+ │ │ ├── edges/
+ │ │ │ └── route.js # api para aristas y conexiones
+ │ │ └── groups/
+ │ │ └── route.js # api para grupos
+ │ ├── layout.jsx # layout ppal de la app
- │ ├── page.jsx # home
- │ └── globals.css # estilos globales
- │
- ├── components/ # componentes react
- │ ├── App.jsx
- │ ├── Graph.jsx
- │ └── Sidebar.jsx
- │
+ ├── services/ # manejo de datos (inicialmente local)
+ │ └── DataService.js
- │
+ ├── lib/ # utilidades y configuraciones
+ │ ├── api-client.js # cliente http centralizado p llamar a la api
+ │ └── constants.js # const compartidas (tipos de nodos, endpoints... )
- │
- ├── data/ # datos estaticos - mocks
- │ ├── nodes.js
- │ ├── edges.js
- │ ├── groups.js
- │ └── index.js #exportacion centralizada de los datos
- │
+ ├── hooks/ #custom react hooks (solo1)
+ │ └── useGraphData.js # con hook para manejar datos del grafo
- │
- ├── utils/ #funciones utilitarias
- │ └── cyutils.js
- │
- ├── styles.css
- ├── styles.js
- ├── index.css
Dependencias
Está sujeto a modificaciones a medida que el proyecto vaya creciendo
npm ls
├── @emnapi/runtime@1.8.1 extraneous
├── babel-plugin-react-compiler@1.0.0
├── cytoscape-cola@2.5.1
├── cytoscape-cose-bilkent@4.1.0
├── cytoscape-fcose@2.2.0
├── cytoscape@3.33.1
├── eslint-config-next@16.1.5
├── eslint@9.39.2
├── next@16.1.5
├── react-dom@19.2.3
└── react@19.2.3
2. Creación de DataService (local)
Creación de src/services/DataService.js que retorna datos desde src/data (el acceso a datos sigue siendo local)
Centraliza todo el acceso a los datos de la app, cuando se pase a una API real solo cambia la implementación interna de los métodos sin tocar el resto de la app (con suerte funciona)
ahora: lógica centralizada para tener los nodos conectados
class DataService {
constructor() {
this.dependencyEdges = nodes
.filter(node => node.data.dependency) //solo nodos con padre
.map(node => ({
data: {
source: node.data.id, //nodo hijo
target: node.data.dependency //nodo padre
}
}));
this.allEdges = systemEdges.concat(this.dependencyEdges);
this.nodes = nodes;
this.groups = groups;
}
..
A simple vista el código esta raro.
Primero que nada porque hay funciones async siendo que, estamos consumiendo los datos localmente y eso se resuelve inmediatamente. Esto tiene una razón y es que la idea sigue siendo solo realizar pequeñas modificaciones en el código al integrar la API real.
async getAllNodes() {
return Promise.resolve(this.nodes);
}
Entonces, ya estan definidas de manera tal que al llamarlas tengamos que hacer await cada vez que las llamamos.
const algo = await dataService.searchNodes(searchText);
|-----|
Todo esto en mis sueños sirve para que la migración a la api sea mas amena, es decir, no tengo que buscar quien lo llama y de donde porque ya esta preparadito.
3. Creación del hook - useGraphData
Creación de src/hooks/useGraphData.js
Centraliza toda la lógica para obtener y manipular los datos:
Por qué esto? porque todo lo relacionado a los datos del grafo esta en un solo lugar, la lógica de datos está separada de la interfaz
-> ANTES, por ejemplo, el filtrado se re-implementaba en cada componente
- cuando arranca el componente, carga los datos iniciales automáticamente (ejecuta
useEffect->loadInitialData()) - el usuario selecciona nodos, filtra por grupos y demás
- el hook actualiza estados y maneja llamadas a la api
- el componente en la interfaz se re-renderiza con nuevos datos
COSITAS:
useState - Manejo de Estado
= permite agregar un estado a componentes funcionales
const [valor, setValor] = useState(valorInicial);
-> elements: almacena nodos y aristas del grafo
const [elements, setElements] = useState({ nodes: [], edges: [] });
-> groups: lista de grupos disponibles
const [groups, setGroups] = useState([]);
-> selection: nodos que están seleccionados
const [selection, setSelection] = useState({ selectedNodes: [] });
-> loading: indica si hay una operación en curso
const [loading, setLoading] = useState(true);
-> error: mensajes de error
const [error, setError] = useState(null);
useEffect - efectos
=maneja efectos secundarios en componentes funcionales - operaciones que afectan algo fuera del componente
useEffect(() => {
// codigo que se ejecuta
return () => {
};
}, [dependencias]); //array de dependencias
useCallBack
como y xq lo uso acá?
const selectNode = useCallback(async (nodeId) => {
// funcion
}, []); //array de dependencias
porque los callbacks se pasan a componentes hijos del grafo que se re-renderizan mucho y cytoscape es pesadito
- memoriza la función entre renders
- solo crea la función si cambian las dependencias
- evita renders innecesarios en componentes hijos
// SIN useCallback - SE CREA UNA FUNCION NUEVA EN CADA RENDER
const selectNode = (nodeId) => {
const connectedNodes = await dataService.getConnectedNodes(nodeId);
setSelection({ selectedNodes: connectedNodes }); };
// problem
// render 1 - selectNode: fA (ref memoria 1)
// render 2 - selectNode: fB (ref memoria 2)
// eender 3 - selectNode: fC (ref memoria 3)
Cada vez que un componente se rerenderiza, js crea una funcion nueva aunque el código sea igual, la referencia en memoria cambia >> react compara referencias >> los componentes hijos que reciben esta funcion se rerenderizan innesesariamente.
tenemos muchos nodos -> cada nodo recibe callbacks (onClick y esas) sin useCallback cada render crea funciones nuevas, todos los nodos se re-renderizan cytoscape es pesado y re-renderizar todo el grafo puede afectar un montón a la perfo
-> selectNode - callback para seleccionar un nodo
const selectNode = useCallback(async (nodeId) => {
try {
const connectedNodes = await dataService.getConnectedNodes(nodeId);
setSelection({ selectedNodes: connectedNodes });
} catch (err) {
console.error('Error seleccionando nodo:', err);
}
}, []); //array vacio porque no tiene dependencias, nunk se vuelve a crear
- Es un callback porque se va a pasar a componentes hijos
- async/await para manejar promesas
-> selectNodes - callback para multiples nodos: solo actualiza el estado, no necesita async
const selectNodes = useCallback((nodeIds) => {
setSelection({ selectedNodes: nodeIds });
}, []);
-> clearSelection - callback para limpiar: sin parametros, limpia el estado de seleccionn
const clearSelection = useCallback(() => {
setSelection({ selectedNodes: [] });
}, []);
-> showAllElements - con 1 dependencia
const showAllElements = useCallback(async () => {
// ... codigo
}, [clearSelection]); //DEPENDE de clearSelection
- Si
clearSelectioncambia, se recrea esta funcion pero comoclearSelectiontambién usauseCallback([]), nunca cambia...
Primeros tests en el front
Creo que la idea es migrar desp a una cosa mas piola como jest, pero por ahora sirven los visuales para chequear el dataservice y el hook - basic
Validación de datos locales (xahora)
1. src/test/dataservice/page.jsx - Data Service Test
npm run dev- entrar a http://localhost:3000/test/dataservice
Lo que se ve es una prueba de todas las funciones declaradas en dataservice y su resultado visual en el front al hacer click en la lista de resultados.
2. src/test/hook/page.jsx - useGraphData Test
npm run dev- entrar a http://localhost:3000/test/hook
Acá aparece useMemo() de React:
Evita calculos costosos con cada render:
const nodosVisibles = useMemo(() => {
if (nodosPersonalizados) return nodosPersonalizados;
return elements.nodes // filtrar + mapear TODA la lista
.filter(n => n?.data?.id)
.map(n => n.data.id);
}, [elements.nodes, nodosPersonalizados]);
Sin useMemo -> cada vez que el componente se renderiza por cualquier cambio de estado, se recorre y procesa todo el array de nodos otra vez.
importante por la rerenderizacion que dije antes de cytoscape (por búsqueda, selección, lo que sea) - useMemo memoriza el resultado y solo recalcula cuando cambian las dependencias
haciendo este me encontre con este error "Encountered two children with the same key 'cgp'..."
2. src/test/hook/data-validation.jsx - data validation
Entonces, hay otro test de validación de datos con dos tipos de mensajes:
- ERROR: rompe la funcionalidad y tiene que corregirse
- ADVERTENCIA: no es lo ideal pero no jode
En este punto encontré problemas en los datos: ids duplicados, conexiones apuntando a nodos inexistentes y grupos con referencias que tampoco existían
Cambios que hice: arreglo errores y agrego nodos de prueba.
en: src/data/edges.js
Elimino duplicado:
{ data: { source: 'dgcye-fin', target: 'esc-liq-alc' } },
Agrego:
// conn economia
{ data: { source: 'mec-info', target: 'test-econ-1' } },
{ data: { source: 'test-econ-1', target: 'sigaf' } },
{ data: { source: 'test-econ-2', target: 'sulh' } },
// conn escuelas
{ data: { source: 'dgcye-rrhh', target: 'test-esc-1' } },
{ data: { source: 'dgcye-fin', target: 'test-esc-2' } },
{ data: { source: 'test-esc-2', target: 'test-esc-3' } },
// conn inv publica
{ data: { source: 'bapin', target: 'test-inv-1' } },
{ data: { source: 'test-inv-1', target: 'tablero' } },
en: src/groups/nodes.js
Agrego:
// economia
{ data: { id: 'test-econ-1', label: 'TEST Sistema Economía 1' }, classes: 'accion' },
{ data: { id: 'test-econ-2', label: 'TEST Sistema Economía 2' }, classes: 'accion' },
// escuelas
{ data: { id: 'test-esc-1', label: 'TEST Sistema Escuelas 1' }, classes: 'accion' },
{ data: { id: 'test-esc-2', label: 'TEST Sistema Escuelas 2' }, classes: 'accion' },
{ data: { id: 'test-esc-3', label: 'TEST Liquidación Prueba' }, classes: 'accion' },
// inversión publica
{ data: { id: 'test-inv-1', label: 'TEST Proyecto Monitor' }, classes: 'accion' },
en src/data/groups.js
Agrego:
GRUPO DE PRUEBA - EXTRA
{
title: 'TEST-Economía',
icono: "recursos.png",
nodeIds: ['mec', 'mec-info', 'test-econ-1', 'test-econ-2', 'sigaf', 'sulh']
}
Elimino duplicado:
Ecosistema salarial -> tenía duplicado 'cgp'
4. Migración de componentes
1. App.js ---> App.jsx
Se movió la lógica del filtrado de elementos a useGraphData
Antes en src/components/App.js:
const filterElements = (allElements, selection) => {
const nodes = allElements.nodes.filter(({ data: { id }}) => selection.selectedNodes.includes(id))
const edges = allElements.edges.filter(({ data: { source, target } }) =>
selection.selectedNodes.includes(source) && selection.selectedNodes.includes(target))
return ({ nodes, edges })
}
El resto se mantiene igual a la espera de cambiar el tipo de grafo.
1. Sidebar.js ---> Sidebar.jsx
Cambios al momento de filtrar elementos en la búsqueda:
Antes en src/components/App.js:
// Filtrar nodos dinámicamente al cambiar el texto del filtro
useEffect(() => {
const lowerCaseFilter = filterText.toLowerCase();
if (!filterText.trim()) {
// Si no hay texto en el filtro, no mostrar ningún nodo
setFilteredNodes([]);
return;
}
const filtered = allNodes.filter((node) => {
return node.data.label.toLowerCase().includes(lowerCaseFilter)
});
setFilteredNodes(filtered);
}, [filterText, allNodes]);
Ahora:
useEffect(() => {
const nuevaBusqueda = async () => {
if (!filterText.trim()) {
setNodesFiltrados([]);
return;
}
const res = await searchNodes(filterText);
setNodesFiltrados(res);
};
nuevaBusqueda();
}, [filterText, searchNodes]);
1. Graph.js ---> Graph.jsx
Sigue exactamente igual.
Proyecto original
Descripción
Sistema de nodos de relaciona sistemas informáticos entre si y con las áreas que lo administran.
Arquitectura
App creada con CRA Create React App documentation. Librería: Cytoscape Cliente pesado. Está deployado con gitlab pages: https://mapa-sistemas-181a7a.gitlab.io Aún no trabaja con una base de datos, la información está formato json.
Datos
Los nodos están en data/nodes.js
Las relaciones están en data/edges.js
Hay dos tipos de nodos, los que representan sistemas informáticos y los que representan áreas. Los nodos de sistemas se relacionan entre si si interoperan de alguna manera. Puede ser manual (con un archivo para se pasa manuelamente de uno a otro), o a través de apis. En este caso, es manual. Cada nodo sistema, se relaciona con todas aquellas áreas que
Cada Nodo tiene principalmente, un label y un class, que dependerá del tipo de nodo.
Hay dos tipos de nodos, los que representan un área, y los que representan un sistema. Dentro de los que representan un área los class son:
- org (si es un organismo)
- nivel 1 (dependencias directa de org, por ejemplo, subsecretarias)
- nivel 2 (dependencias directas del nivel 1, por ejemplo, direcciones provinciales) y si es una sistema el class es:
- sistema
También cada nodo de tipo área tiene el parent si corresponde. Y aquellos que son de nivel 2 también tienen un sublabel con un texto que representa el organismo al que pertenecen.
Cada Arista tiene los ides de los nodos que conecta: source y target.
Estructuras de datos
Los datos se importan desde la carpeta data en App.js. Estos datos provienen de index.js que proporciona los siguientes datos:
- groups
- elements: { nodes, edges }
groups es una estructura que contiene 3 datos:
- title: título del grupo
- icono: un ícono para ese grupo (para la vista)
- nodeIds: una lista con los id's de los nodos que conforman ese grupo
y elements es una estructura que contiene:
- nodes: todos los nodos
- edjes: todas las aristas
Luego en App se utilizan las siguientes estructuras:
- selection: {selectedNodes: [nodeId]} - conjunto de nodos
- elements: es una lista con nodes y edges. Pueden estar todos los nodos y edges o sólo un grupo.
Sidebar
Atributos: data, selection, setSelectiond, mostrarSoloGrupo, toggleMostrarSoloGrupo Funcionalidades: Por una lado el de filtro, por otro el de grupos predefinidos y por último, la función de filtrar nodos.
-
Filtro: El filtro permite el input de texto, con ese texto recorrerá filteredNodes, eleccionará aquellos cuyo label contengan ese texto y mostrará una lista con esos labels (filteredNOdes es una lista de todos los nodos ({id, label})). Esos labels a su vez son botones, que al ser clickeados, con el id que mapea dicho label, se generará el objeto selection: {[nodeId]}.
Con nodeId se calculará la lista de nodos de se realcionan con él y se guardarán en selection.
Con setSelection se actualizará el grafo. -
Grupos Predefinido: El grupo predefinido tiene configurados un grupo de ids. Al clickear sobre un de estos botones, también genera el objeto selection, con la lista de ids.
Las aristas se calcularan a partir de los nodos pertenecientes al grupo.
Luego Sidebar tiene varios filtros automáticos que aplican sobre el gráfico:
- showOrg: Muestra u oculta los organismos. El objetivo es simplificar el gráfico a las áreas consumidoras o productoras directamente.
- mostrarSoloGrupo: Si se selecciona esta opción, el gráfico muestra sólamente el conjunto de nodos seleccionados. De la otra manera, este conjuntos queda resaltado en el grafo pero con todos los demás nodos por detrás.
Graph
Aatributos: elements, selection, setSelection, showOrg
Su principal tarea es gestionar cytoscape.
Al clickear en un nodo, se ejectua el setSelection.
Organizacion del proyecto:
src/
|
├── components/ # componentes
│ ├── App.js
│ ├── Graph.js
│ └── Sidebar.js
│
├── data/ # datos json
│ ├── nodes.js
│ ├── edges.js
│ ├── groups.js
│ └── index.js
└── utils/ # utilidades
└── cyutils.js
Al hacer npm ls en la consola:
mapa-sistemas@0.1.0 C:\Users\Pc\Desktop\aaaa\grafos
├── @testing-library/jest-dom@5.17.0
├── @testing-library/react@13.4.0
├── @testing-library/user-event@13.5.0
├── cytoscape-cola@2.5.1
├── cytoscape-cose-bilkent@4.1.0
├── cytoscape-fcose@2.2.0
├── cytoscape@3.33.1
├── react-dom@18.3.1
├── react-scripts@5.0.1
├── react@18.3.1
└── web-vitals@2.1.4