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:
@@ -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
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user