feat: componente GraphContainer con sigma.js y graphology

Visualizacion interactiva de grafos con WebGL via sigma.js, estructura de
datos graphology, y layout ForceAtlas2 adaptativo. Soporta grafos dirigidos
multi-edge, leyenda de tipos de nodo, y eventos click/double-click.
Nuevas deps: graphology, sigma, recharts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 15:02:34 +02:00
parent 74b4c40f18
commit 9b3c57471f
4 changed files with 700 additions and 0 deletions
@@ -0,0 +1,103 @@
---
name: graph_container
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "GraphContainer(props: GraphContainerProps): JSX.Element"
description: "Interactive graph visualization with sigma.js, graphology, and ForceAtlas2 layout"
tags: [component, ui, graph, visualization, sigma, graphology, network]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["graphology", "graphology-layout-forceatlas2", "sigma"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/graph/index.tsx"
props:
- name: data
type: "GraphData"
required: true
description: "Graph data with nodes and edges arrays"
- name: layout
type: "'organic' | 'random'"
required: false
description: "Layout algorithm (default: organic/ForceAtlas2)"
- name: showLegend
type: "boolean"
required: false
description: "Show node type legend overlay"
- name: nodeTypes
type: "NodeType[]"
required: false
description: "Node type definitions for legend"
- name: onNodeClick
type: "(node: GraphNode) => void"
required: false
description: "Node click handler"
- name: onNodeDoubleClick
type: "(node: GraphNode) => void"
required: false
description: "Node double-click handler"
- name: theme
type: "GraphTheme"
required: false
description: "Visual theme overrides"
- name: height
type: "string | number"
required: false
description: "Container height (default: 100%)"
- name: className
type: "string"
required: false
description: "Additional CSS classes"
emits: []
has_state: true
framework: react
variant: []
---
## Ejemplo
```tsx
import { GraphContainer } from '@fn_library/graph'
import type { GraphData } from '@fn_library/graph'
const data: GraphData = {
nodes: [
{ id: '1', label: 'Node A', color: '#e74c3c', size: 10 },
{ id: '2', label: 'Node B', color: '#3498db', size: 8 },
],
edges: [
{ id: 'e1', source: '1', target: '2', label: 'connects', type: 'arrow' },
],
}
function MyGraph() {
return (
<GraphContainer
data={data}
layout="organic"
showLegend
nodeTypes={[
{ type: 'person', color: '#e74c3c', label: 'Person' },
{ type: 'org', color: '#3498db', label: 'Organization' },
]}
onNodeClick={(node) => console.log('clicked:', node.id)}
height="500px"
/>
)
}
```
## Notas
- Usa graphology como estructura de datos de grafo
- ForceAtlas2 para layout organico (iterations adaptativas segun numero de nodos)
- Sigma.js para renderizado WebGL de alto rendimiento
- Soporta grafos dirigidos multi-edge
- El componente limpia la instancia Sigma al desmontar
+237
View File
@@ -0,0 +1,237 @@
import * as React from "react"
import Graph from "graphology"
import forceAtlas2 from "graphology-layout-forceatlas2"
import { Sigma } from "sigma"
// ── Types ─────────────────────────────────────────────────────────────────
export interface GraphNode {
id: string
label: string
type?: string
color?: string
size?: number
x?: number
y?: number
[key: string]: unknown
}
export interface GraphEdge {
id: string
source: string
target: string
label?: string
color?: string
size?: number
type?: "arrow" | "line"
weight?: number
[key: string]: unknown
}
export interface GraphData {
nodes: GraphNode[]
edges: GraphEdge[]
}
export interface NodeType {
type: string
color: string
label: string
}
export interface GraphTheme {
backgroundColor?: string
nodeColor?: string
nodeSize?: number
edgeColor?: string
edgeSize?: number
labelColor?: string
selectionColor?: string
}
export interface GraphContainerProps {
data: GraphData
layout?: "organic" | "random"
showToolbar?: boolean
showLegend?: boolean
showMinimap?: boolean
nodeTypes?: NodeType[]
onNodeClick?: (node: GraphNode) => void
onNodeDoubleClick?: (node: GraphNode) => void
enableSelection?: boolean
selectionMode?: "single" | "multiple"
theme?: GraphTheme
height?: string | number
className?: string
}
const DEFAULT_THEME: Required<GraphTheme> = {
backgroundColor: "var(--background, #0a0a0f)",
nodeColor: "#95a5a6",
nodeSize: 8,
edgeColor: "rgba(255,255,255,0.19)",
edgeSize: 1,
labelColor: "#e0e0e0",
selectionColor: "#3b82f6",
}
// ── Component ─────────────────────────────────────────────────────────────
function GraphContainer({
data,
layout = "organic",
showLegend = false,
nodeTypes = [],
onNodeClick,
onNodeDoubleClick,
theme: themeProp,
height = "100%",
className,
}: GraphContainerProps) {
const containerRef = React.useRef<HTMLDivElement>(null)
const sigmaRef = React.useRef<Sigma | null>(null)
const graphRef = React.useRef<Graph | null>(null)
const theme = React.useMemo(
() => ({ ...DEFAULT_THEME, ...themeProp }),
[themeProp],
)
// Build + render
React.useEffect(() => {
const el = containerRef.current
if (!el) return
// Cleanup previous instance
if (sigmaRef.current) {
sigmaRef.current.kill()
sigmaRef.current = null
}
const g = new Graph({ multi: true, type: "directed" })
graphRef.current = g
// Add nodes
for (const n of data.nodes) {
g.addNode(n.id, {
label: n.label,
x: n.x ?? (Math.random() - 0.5) * 10,
y: n.y ?? (Math.random() - 0.5) * 10,
size: n.size ?? theme.nodeSize,
color: n.color ?? theme.nodeColor,
type: n.type,
})
}
// Add edges
for (const e of data.edges) {
try {
g.addEdgeWithKey(e.id, e.source, e.target, {
label: e.label,
size: e.size ?? theme.edgeSize,
color: e.color ?? theme.edgeColor,
type: e.type === "arrow" ? "arrow" : "line",
weight: e.weight ?? 1,
})
} catch {
// skip duplicate keys
}
}
// Layout
if (layout === "organic" && g.order > 0) {
forceAtlas2.assign(g, {
iterations: Math.min(500, Math.max(100, g.order * 5)),
settings: {
gravity: 1,
scalingRatio: 2,
slowDown: 5,
barnesHutOptimize: g.order > 300,
},
})
}
// Render
const renderer = new Sigma(g, el, {
renderEdgeLabels: false,
defaultEdgeColor: theme.edgeColor,
defaultNodeColor: theme.nodeColor,
labelColor: { color: theme.labelColor },
labelSize: 11,
})
sigmaRef.current = renderer
// Events
if (onNodeClick) {
renderer.on("clickNode", ({ node }) => {
const attrs = g.getNodeAttributes(node)
onNodeClick({ id: node, ...attrs } as unknown as GraphNode)
})
}
if (onNodeDoubleClick) {
renderer.on("doubleClickNode", ({ node }) => {
const attrs = g.getNodeAttributes(node)
onNodeDoubleClick({ id: node, ...attrs } as unknown as GraphNode)
})
}
return () => {
renderer.kill()
sigmaRef.current = null
graphRef.current = null
}
}, [data, layout, theme, onNodeClick, onNodeDoubleClick])
// Container background
const containerStyle: React.CSSProperties = {
height,
width: "100%",
position: "relative",
background: theme.backgroundColor,
borderRadius: "var(--radius, 0.5rem)",
overflow: "hidden",
}
return (
<div className={className} style={containerStyle}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{showLegend && nodeTypes.length > 0 && (
<div
style={{
position: "absolute",
top: 12,
right: 12,
background: "rgba(0,0,0,0.7)",
backdropFilter: "blur(6px)",
borderRadius: 8,
padding: "10px 14px",
fontSize: 12,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{nodeTypes.map((nt) => (
<div
key={nt.type}
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: nt.color,
flexShrink: 0,
}}
/>
<span style={{ color: theme.labelColor }}>{nt.label}</span>
</div>
))}
</div>
)}
</div>
)
}
export { GraphContainer }