/** * SigmaGraph — Obsidian-style knowledge graph using Sigma.js + graphology + ForceAtlas2. * * WebGL renderer (smooth on 1k+ nodes), ForceAtlas2 layout (the algorithm Gephi & Obsidian use), * edges thinned by intensity, click → side panel. */ "use client"; import "@react-sigma/core/lib/style.css"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { UndirectedGraph } from "graphology"; import forceAtlas2 from "graphology-layout-forceatlas2"; import { SigmaContainer, useLoadGraph, useRegisterEvents, useSigma, useSetSettings, } from "@react-sigma/core"; interface RawNode { entity_pk: number; entity_class: string; entity_id: string; canonical_name: string; total_mentions: number; documents_count: number; } interface RawLink { source: number; target: number; weight: number; } const CLASS_COLOR: Record = { person: "#ff6ec7", organization: "#ff8a4d", location: "#3fde6a", event: "#ffa500", uap_object: "#ff3344", vehicle: "#5b9bd5", operation: "#9b5de5", concept: "#06d6a0", }; const CLASS_FOLDER: Record = { person: "people", organization: "organizations", location: "locations", event: "events", uap_object: "uap-objects", vehicle: "vehicles", operation: "operations", concept: "concepts", }; const CLASS_LABEL: Record = { person: "Pessoas", organization: "Organizações", location: "Locais", event: "Eventos", uap_object: "UAP", vehicle: "Veículos", operation: "Operações", concept: "Conceitos", }; const ALL_CLASSES = [ "person", "organization", "location", "event", "uap_object", "vehicle", "operation", "concept", ]; function edgeColor(weight: number): string { if (weight >= 10) return "#00ff9c"; if (weight >= 5) return "#7fdbff"; if (weight >= 3) return "#a78bfa"; return "#3a4a5e"; } interface NodeAttrs { label: string; color: string; size: number; entity_class: string; entity_id: string; canonical_name: string; total_mentions: number; documents_count: number; x?: number; y?: number; hidden?: boolean; } interface SigmaPayload { nodes: RawNode[]; links: RawLink[]; } function GraphLoader({ payload, visibleClasses, hoverNodeKey, selectedNodeKey, onSelect, onHover, }: { payload: SigmaPayload; visibleClasses: Set; hoverNodeKey: string | null; selectedNodeKey: string | null; onSelect: (key: string | null) => void; onHover: (key: string | null) => void; }) { const loadGraph = useLoadGraph(); const registerEvents = useRegisterEvents(); const setSettings = useSetSettings(); const sigma = useSigma(); // (Re)load graph when payload changes useEffect(() => { const graph = new UndirectedGraph(); const visible = payload.nodes.filter((n) => visibleClasses.has(n.entity_class)); const allowedPks = new Set(visible.map((n) => n.entity_pk)); // First pass: count degree from links so we can drop isolated nodes const degree = new Map(); for (const l of payload.links) { if (!allowedPks.has(l.source) || !allowedPks.has(l.target)) continue; if (l.source === l.target) continue; degree.set(l.source, (degree.get(l.source) ?? 0) + 1); degree.set(l.target, (degree.get(l.target) ?? 0) + 1); } // Drop isolates — FA2 lays them out as ugly arcs along gravity contours. for (const n of visible) { const d = degree.get(n.entity_pk) ?? 0; if (d === 0) continue; const angle = Math.random() * 2 * Math.PI; const r = Math.sqrt(Math.random()) * 4; const sizePx = Math.max(3, Math.min(22, 3 + Math.sqrt(d) * 2.2)); graph.addNode(String(n.entity_pk), { label: n.canonical_name.length > 36 ? n.canonical_name.slice(0, 34) + "…" : n.canonical_name, color: CLASS_COLOR[n.entity_class] ?? "#7fdbff", size: sizePx, entity_class: n.entity_class, entity_id: n.entity_id, canonical_name: n.canonical_name, total_mentions: n.total_mentions, documents_count: n.documents_count, degree: d, x: Math.cos(angle) * r, y: Math.sin(angle) * r, } as NodeAttrs); } for (const l of payload.links) { if (!allowedPks.has(l.source) || !allowedPks.has(l.target)) continue; const s = String(l.source); const t = String(l.target); if (!graph.hasNode(s) || !graph.hasNode(t) || graph.hasEdge(s, t)) continue; graph.addEdge(s, t, { size: Math.max(0.6, Math.min(6, Math.log2(l.weight + 1) * 1.3)), color: edgeColor(l.weight), weight: l.weight, }); } if (graph.order === 0) { loadGraph(graph); return; } // ForceAtlas2 — Obsidian-like clustering. Two passes: aggressive spread, then settle. // Pass 1: violent spread to escape the hub-collapse local minimum forceAtlas2.assign(graph, { iterations: 300, settings: { gravity: 0.05, scalingRatio: 80, slowDown: 1, adjustSizes: false, barnesHutOptimize: graph.order > 150, barnesHutTheta: 0.5, linLogMode: false, strongGravityMode: false, outboundAttractionDistribution: true, edgeWeightInfluence: 0.4, }, }); // Pass 2: settle, prevent overlap forceAtlas2.assign(graph, { iterations: 700, settings: { gravity: 0.08, scalingRatio: 55, slowDown: 12, adjustSizes: true, barnesHutOptimize: graph.order > 150, barnesHutTheta: 0.5, linLogMode: false, strongGravityMode: false, outboundAttractionDistribution: true, edgeWeightInfluence: 0.6, }, }); // Recenter graph on its centroid so animatedReset puts it at viewport center. let cx = 0; let cy = 0; let count = 0; graph.forEachNode((_, attrs) => { cx += (attrs as { x: number }).x; cy += (attrs as { x: number; y: number }).y; count += 1; }); if (count > 0) { cx /= count; cy /= count; graph.forEachNode((node, attrs) => { graph.setNodeAttribute(node, "x", (attrs as { x: number }).x - cx); graph.setNodeAttribute(node, "y", (attrs as { y: number }).y - cy); }); } loadGraph(graph); // Defer camera fit until Sigma indexes the new graph in normalized space. setTimeout(() => { const cam = sigma.getCamera(); // Compute bbox of node coords in viewport (rendered) space and zoom to fit. let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity; sigma.getGraph().forEachNode((nodeKey) => { const display = sigma.getNodeDisplayData(nodeKey); if (!display) return; xMin = Math.min(xMin, display.x); xMax = Math.max(xMax, display.x); yMin = Math.min(yMin, display.y); yMax = Math.max(yMax, display.y); }); if (!isFinite(xMin)) { cam.animatedReset({ duration: 400 }); return; } const extent = Math.max(xMax - xMin, yMax - yMin); // sigma camera ratio: 1 == default; the visible viewport in normalized space is ~1 unit. // Use extent * padding so cluster fills ~75% of the view. const ratio = Math.max(0.3, extent * 1.15); cam.animate( { x: (xMin + xMax) / 2, y: (yMin + yMax) / 2, ratio, angle: 0, }, { duration: 700 }, ); }, 200); }, [payload, visibleClasses, loadGraph, sigma]); // Highlight on hover/select via dynamic settings useEffect(() => { setSettings({ nodeReducer: (node, data) => { const isSelected = selectedNodeKey === node; const isHovered = hoverNodeKey === node; const graph = sigma.getGraph(); const neighbors = selectedNodeKey ? new Set(graph.neighbors(selectedNodeKey)) : null; const isNeighborOfSelected = neighbors?.has(node) ?? false; const dim = selectedNodeKey && !isSelected && !isNeighborOfSelected; return { ...data, highlighted: isHovered || isSelected, color: dim ? "#1a2434" : (data as { color?: string }).color, label: isSelected || isHovered || isNeighborOfSelected || (data as { degree?: number }).degree! >= 4 ? data.label : "", forceLabel: isSelected || isHovered, zIndex: isSelected ? 3 : isHovered ? 2 : 1, }; }, edgeReducer: (edge, data) => { if (!selectedNodeKey) return data; const graph = sigma.getGraph(); const [s, t] = graph.extremities(edge); const touches = s === selectedNodeKey || t === selectedNodeKey; return { ...data, color: touches ? (data as { color?: string }).color : "#0d1421", size: touches ? Math.max(((data as { size?: number }).size ?? 1) * 1.4, 1.5) : (data as { size?: number }).size ?? 1, }; }, labelColor: { color: "#c8d4e6" }, labelSize: 13, labelWeight: "500", labelFont: "system-ui, -apple-system, sans-serif", renderEdgeLabels: false, defaultEdgeColor: "#3a4a5e", defaultNodeColor: "#7fdbff", enableEdgeEvents: false, hideEdgesOnMove: false, hideLabelsOnMove: false, allowInvalidContainer: true, }); }, [selectedNodeKey, hoverNodeKey, setSettings, sigma]); // Mouse events useEffect(() => { registerEvents({ clickNode: ({ node }) => onSelect(node), enterNode: ({ node }) => onHover(node), leaveNode: () => onHover(null), clickStage: () => onSelect(null), }); }, [registerEvents, onSelect, onHover]); return null; } interface EntityDetail { canonical_name: string; entity_class: string; entity_id: string; total_mentions: number; documents_count: number; neighbors: Array<{ entity_pk: number; entity_class: string; entity_id: string; canonical_name: string; weight: number; total_mentions: number; }>; } const detailCache = new Map(); export function SigmaGraph() { const [payload, setPayload] = useState(null); const [selectedClasses, setSelectedClasses] = useState>(new Set(ALL_CLASSES)); const [loading, setLoading] = useState(true); const [limit, setLimit] = useState(80); const [minWeight, setMinWeight] = useState(1); const [search, setSearch] = useState(""); const [hoverKey, setHoverKey] = useState(null); const [selectedKey, setSelectedKey] = useState(null); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); // Load seed useEffect(() => { setLoading(true); const classesParam = Array.from(selectedClasses).join(","); fetch(`/api/graph/seed?limit=${limit}&min_weight=${minWeight}&classes=${classesParam}`) .then((r) => r.json()) .then((data: { nodes?: RawNode[]; links?: RawLink[] }) => { setPayload({ nodes: data.nodes ?? [], links: data.links ?? [] }); setLoading(false); }) .catch(() => setLoading(false)); }, [limit, minWeight, selectedClasses]); // Filter by search (visible classes already done in payload load) const filteredPayload = useMemo(() => { if (!payload) return null; if (!search.trim()) return payload; const s = search.toLowerCase(); const nodes = payload.nodes.filter( (n) => n.canonical_name.toLowerCase().includes(s) || n.entity_id.toLowerCase().includes(s), ); const pks = new Set(nodes.map((n) => n.entity_pk)); const links = payload.links.filter((l) => pks.has(l.source) && pks.has(l.target)); return { nodes, links }; }, [payload, search]); // Selected node attrs (need to look up in payload) const selectedNode = useMemo(() => { if (!selectedKey || !payload) return null; return payload.nodes.find((n) => String(n.entity_pk) === selectedKey) ?? null; }, [selectedKey, payload]); // Load detail on select useEffect(() => { if (!selectedNode) { setDetail(null); return; } const key = `${selectedNode.entity_class}/${selectedNode.entity_id}`; const cached = detailCache.get(key); if (cached) { setDetail(cached); return; } setDetail(null); setDetailLoading(true); fetch( `/api/graph?op=neighbors&class=${selectedNode.entity_class}&id=${encodeURIComponent(selectedNode.entity_id)}&limit=12`, ) .then((r) => r.json()) .then((data: { entity?: RawNode; neighbors?: EntityDetail["neighbors"] }) => { const d: EntityDetail = { canonical_name: data.entity?.canonical_name ?? selectedNode.canonical_name, entity_class: selectedNode.entity_class, entity_id: selectedNode.entity_id, total_mentions: data.entity?.total_mentions ?? selectedNode.total_mentions, documents_count: data.entity?.documents_count ?? selectedNode.documents_count, neighbors: data.neighbors ?? [], }; detailCache.set(key, d); setDetail(d); }) .catch(() => setDetail(null)) .finally(() => setDetailLoading(false)); }, [selectedNode]); const expand = useCallback( async (entityClass: string, entityId: string) => { try { const r = await fetch( `/api/graph?op=neighbors&class=${entityClass}&id=${encodeURIComponent(entityId)}&limit=15`, ); if (!r.ok) return; const data = (await r.json()) as { entity?: RawNode; neighbors?: Array }; if (!data.entity || !data.neighbors) return; setPayload((prev) => { if (!prev) return prev; const existing = new Set(prev.nodes.map((n) => n.entity_pk)); const newNodes = data.neighbors! .filter((n) => !existing.has(n.entity_pk)) .map((n) => ({ entity_pk: n.entity_pk, entity_class: n.entity_class, entity_id: n.entity_id, canonical_name: n.canonical_name, total_mentions: n.total_mentions, documents_count: 0, } as RawNode)); const edgeKey = (a: number, b: number) => `${Math.min(a, b)}-${Math.max(a, b)}`; const existingEdges = new Set(prev.links.map((l) => edgeKey(l.source, l.target))); const newLinks = data.neighbors! .filter((n) => !existingEdges.has(edgeKey(data.entity!.entity_pk, n.entity_pk))) .map((n) => ({ source: data.entity!.entity_pk, target: n.entity_pk, weight: n.weight })); return { nodes: [...prev.nodes, ...newNodes], links: [...prev.links, ...newLinks], }; }); } catch { /* ignore */ } }, [], ); const toggleClass = useCallback((cls: string) => { setSelectedClasses((prev) => { const next = new Set(prev); if (next.has(cls)) next.delete(cls); else next.add(cls); return next.size > 0 ? next : prev; }); }, []); return (
{/* LEFT sidebar */}
🔍 buscar nó
setSearch(e.target.value)} placeholder="nome ou id..." className="w-full bg-transparent border border-[rgba(0,255,156,0.20)] focus:border-[#00ff9c] rounded px-2 py-1.5 font-mono text-xs text-[#c8d4e6] outline-none" />
classes
{ALL_CLASSES.map((cls) => { const active = selectedClasses.has(cls); const color = CLASS_COLOR[cls] ?? "#7fdbff"; return ( ); })}
top entidades
{[20, 40, 80, 150, 300].map((n) => ( ))}
mostrar vínculos com ≥
{[1, 2, 3, 5, 10].map((n) => ( ))}
força do vínculo
≥ 10 co-menções
5–9
3–4
{loading ? "carregando…" : `${filteredPayload?.nodes.length ?? 0} nós · ${filteredPayload?.links.length ?? 0} arestas`}
Sigma.js + ForceAtlas2
{/* RIGHT side panel — selected entity */} {selectedNode && (
{CLASS_LABEL[selectedNode.entity_class] ?? selectedNode.entity_class}

{selectedNode.canonical_name}

{selectedNode.entity_id}
menções
{selectedNode.total_mentions}
documentos
{selectedNode.documents_count}
abrir página completa →
{detailLoading ? "carregando vizinhos…" : `top vínculos (${detail?.neighbors.length ?? 0})`}
{detail?.neighbors && detail.neighbors.length > 0 ? (
    {detail.neighbors.map((n) => { const color = CLASS_COLOR[n.entity_class] ?? "#7fdbff"; return (
  • ); })}
) : !detailLoading ? (

sem co-menções

) : null}
)} {/* Hover tooltip */} {hoverKey && payload && (() => { const n = payload.nodes.find((nn) => String(nn.entity_pk) === hoverKey); if (!n) return null; return (
{n.canonical_name}
{CLASS_LABEL[n.entity_class]} · {n.total_mentions} menções · {n.documents_count} docs
clique para detalhes
); })()} {/* Sigma container */} {filteredPayload && ( )}
); }