#49 feat: servicio genérico de layout por columnas y adaptador cytoscape
This commit is contained in:
73
src/services/graph/graph-layout.js
Normal file
73
src/services/graph/graph-layout.js
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
90
tests/unit/services/graph/graph-layout.spec.js
Normal file
90
tests/unit/services/graph/graph-layout.spec.js
Normal file
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user