From 21376151f0a247bc65897c7fd8a6637b1a77cae8 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 17:13:37 -0500 Subject: [PATCH] =?UTF-8?q?#49=20feat:=20servicio=20gen=C3=A9rico=20de=20l?= =?UTF-8?q?ayout=20por=20columnas=20y=20adaptador=20cytoscape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/graph/graph-layout.js | 73 +++++++++++++++ .../unit/services/graph/graph-layout.spec.js | 90 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/services/graph/graph-layout.js create mode 100644 tests/unit/services/graph/graph-layout.spec.js diff --git a/src/services/graph/graph-layout.js b/src/services/graph/graph-layout.js new file mode 100644 index 0000000..f1eb3e7 --- /dev/null +++ b/src/services/graph/graph-layout.js @@ -0,0 +1,73 @@ +/** + * Servicio genérico de grafos en capas (columnas). + * + * Provee un layout determinista por columnas (un tipo por columna) y un + * adaptador al formato `elements` de cytoscape.js. No conoce el dominio + * (provenance, organigrama, etc.), solo el modelo de grafo: + * node: { id, kind, label, image?, entity? } + * edge: { from, to } (ids de los nodos) + */ + +const DEFAULTS = { + columnWidth: 220, + rowHeight: 64, + padding: 24, +} + +function groupByColumn (nodes, columnOf) { + const columns = {} + for (const node of nodes) { + const column = columnOf(node) ?? 0 + if (!columns[column]) columns[column] = [] + columns[column].push(node) + } + return columns +} + +export function computeColumnLayout (nodes, edges, options = {}) { + const { columnOf, columnWidth = DEFAULTS.columnWidth, rowHeight = DEFAULTS.rowHeight, padding = DEFAULTS.padding } = options + const columns = groupByColumn(nodes, columnOf) + const columnKeys = Object.keys(columns).map(Number) + const maxColumn = columnKeys.length > 0 ? Math.max(...columnKeys) : 0 + const maxRows = columnKeys.length > 0 ? Math.max(...columnKeys.map(c => columns[c].length)) : 0 + + const positionedNodes = nodes.map(node => { + const column = columnOf(node) ?? 0 + const index = columns[column].indexOf(node) + return { + ...node, + x: padding + column * columnWidth + columnWidth / 2, + y: padding + index * rowHeight + rowHeight / 2, + } + }) + + return { + nodes: positionedNodes, + edges, + width: padding * 2 + (maxColumn + 1) * columnWidth, + height: padding * 2 + maxRows * rowHeight, + } +} + +export function toCytoscapeElements ({ nodes, edges }) { + return [ + ...nodes.map(node => ({ + data: { + id: node.id, + kind: node.kind, + label: node.label, + image: node.image || null, + entity: node.entity || null, + }, + classes: [node.kind], + position: { x: node.x, y: node.y }, + })), + ...edges.map(edge => ({ + data: { + id: `edge:${edge.from}:${edge.to}`, + source: edge.from, + target: edge.to, + }, + })), + ] +} diff --git a/tests/unit/services/graph/graph-layout.spec.js b/tests/unit/services/graph/graph-layout.spec.js new file mode 100644 index 0000000..eeb8b23 --- /dev/null +++ b/tests/unit/services/graph/graph-layout.spec.js @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { computeColumnLayout, toCytoscapeElements } from '@/services/graph/graph-layout' + +const nodes = [ + { id: 'product:1', kind: 'product', label: 'Panela' }, + { id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }, + { id: 'organization:3', kind: 'organization', label: 'Red Solidaria' }, +] + +const edges = [ + { from: 'product:1', to: 'supplier:5' }, + { from: 'supplier:5', to: 'organization:3' }, +] + +function columnOf (node) { + return { product: 0, supplier: 1, organization: 2 }[node.kind] +} + +describe('computeColumnLayout', () => { + it('ubica cada nodo en su columna según la función columnOf', () => { + const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + + const product = result.nodes.find(n => n.id === 'product:1') + const supplier = result.nodes.find(n => n.id === 'supplier:5') + const organization = result.nodes.find(n => n.id === 'organization:3') + + expect(product.x).toBeLessThan(supplier.x) + expect(supplier.x).toBeLessThan(organization.x) + expect(product.x).toBeCloseTo(20 + 0 * 200 + 100) + expect(supplier.x).toBeCloseTo(20 + 1 * 200 + 100) + expect(organization.x).toBeCloseTo(20 + 2 * 200 + 100) + }) + + it('separa verticalmente los nodos que comparten columna y conserva el orden', () => { + const many = [ + { id: 'supplier:1', kind: 'supplier', label: 'A' }, + { id: 'supplier:2', kind: 'supplier', label: 'B' }, + ] + const result = computeColumnLayout(many, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + + const a = result.nodes.find(n => n.id === 'supplier:1') + const b = result.nodes.find(n => n.id === 'supplier:2') + + expect(a.y).toBeCloseTo(20 + 80 / 2) + expect(b.y).toBeCloseTo(20 + 80 + 80 / 2) + expect(a.y).toBeLessThan(b.y) + }) + + it('calcula ancho y alto del lienzo según columnas y filas usadas', () => { + const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + + expect(result.width).toBeCloseTo(20 * 2 + 3 * 200) + expect(result.height).toBeCloseTo(20 * 2 + 80) + }) + + it('respeta el orden de aparición de los nodos', () => { + const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + + expect(result.nodes.map(n => n.id)).toEqual(['product:1', 'supplier:5', 'organization:3']) + }) +}) + +describe('toCytoscapeElements', () => { + it('convierte nodos y aristas al formato elements de cytoscape', () => { + const layout = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + const elements = toCytoscapeElements(layout) + + const productNode = elements.find(e => e.data.id === 'product:1') + expect(productNode).toMatchObject({ + position: { x: productNode.position.x, y: productNode.position.y }, + classes: ['product'], + data: { id: 'product:1', kind: 'product', label: 'Panela' }, + }) + expect(typeof productNode.position.x).toBe('number') + expect(typeof productNode.position.y).toBe('number') + + const edge = elements.find(e => e.data.source === 'product:1') + expect(edge).toMatchObject({ data: { source: 'product:1', target: 'supplier:5' } }) + }) + + it('incluye datos extra del nodo en data.entity', () => { + const withEntity = [ + { id: 'product:1', kind: 'product', label: 'Panela', entity: { name: 'Panela', price: 3000 } }, + ] + const layout = computeColumnLayout(withEntity, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + const elements = toCytoscapeElements(layout) + + expect(elements[0].data.entity).toEqual({ name: 'Panela', price: 3000 }) + }) +})