disclosure-bureau/web/components/sigma-graph.tsx

752 lines
27 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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<string, string> = {
person: "#ff6ec7",
organization: "#ff8a4d",
location: "#3fde6a",
event: "#ffa500",
uap_object: "#ff3344",
vehicle: "#5b9bd5",
operation: "#9b5de5",
concept: "#06d6a0",
};
const CLASS_FOLDER: Record<string, string> = {
person: "people",
organization: "organizations",
location: "locations",
event: "events",
uap_object: "uap-objects",
vehicle: "vehicles",
operation: "operations",
concept: "concepts",
};
const CLASS_LABEL: Record<string, string> = {
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<string>;
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<number, number>();
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<string, EntityDetail>();
export function SigmaGraph() {
const [payload, setPayload] = useState<SigmaPayload | null>(null);
const [selectedClasses, setSelectedClasses] = useState<Set<string>>(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<string | null>(null);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [detail, setDetail] = useState<EntityDetail | null>(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<SigmaPayload | null>(() => {
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<RawNode & { weight: number }> };
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 (
<div className="relative w-full h-full bg-[#040810] overflow-hidden">
{/* LEFT sidebar */}
<div className="absolute top-20 left-4 z-20 w-[240px] max-h-[calc(100vh-180px)] overflow-y-auto bg-[#0a121e]/95 backdrop-blur border border-[rgba(0,255,156,0.20)] rounded p-3 space-y-4">
<div>
<div className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] mb-2">
🔍 buscar
</div>
<input
value={search}
onChange={(e) => 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"
/>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] mb-2">
classes
</div>
<div className="space-y-1">
{ALL_CLASSES.map((cls) => {
const active = selectedClasses.has(cls);
const color = CLASS_COLOR[cls] ?? "#7fdbff";
return (
<button
key={cls}
onClick={() => toggleClass(cls)}
className={`w-full flex items-center gap-2 px-2 py-1 font-mono text-[11px] rounded border transition ${
active ? "" : "opacity-30 hover:opacity-60"
}`}
style={{
color,
borderColor: color,
background: active ? `${color}12` : "transparent",
}}
>
<span className="inline-block w-2 h-2 rounded-full" style={{ background: color }} />
<span className="flex-1 text-left">{CLASS_LABEL[cls]}</span>
{active && <span></span>}
</button>
);
})}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] mb-2">
top entidades
</div>
<div className="flex flex-wrap gap-1">
{[20, 40, 80, 150, 300].map((n) => (
<button
key={n}
onClick={() => setLimit(n)}
className={`px-2 py-1 font-mono text-[11px] rounded border ${
limit === n
? "border-[#00ff9c] text-[#00ff9c] bg-[rgba(0,255,156,0.10)]"
: "border-[rgba(127,219,255,0.20)] text-[#8896aa] hover:text-[#7fdbff]"
}`}
>
{n}
</button>
))}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] mb-2">
mostrar vínculos com
</div>
<div className="flex flex-wrap gap-1">
{[1, 2, 3, 5, 10].map((n) => (
<button
key={n}
onClick={() => setMinWeight(n)}
className={`px-2 py-1 font-mono text-[11px] rounded border ${
minWeight === n
? "border-[#00ff9c] text-[#00ff9c] bg-[rgba(0,255,156,0.10)]"
: "border-[rgba(127,219,255,0.20)] text-[#8896aa] hover:text-[#7fdbff]"
}`}
>
{n}×
</button>
))}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] mb-2">
força do vínculo
</div>
<div className="space-y-1 text-[10px] font-mono">
<div className="flex items-center gap-2">
<span className="inline-block w-6 h-0.5" style={{ background: "#00ff9c" }} />
<span className="text-[#8896aa]"> 10 co-menções</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-6 h-0.5" style={{ background: "#7fdbff" }} />
<span className="text-[#8896aa]">59</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-6 h-0.5" style={{ background: "#a78bfa" }} />
<span className="text-[#8896aa]">34</span>
</div>
</div>
</div>
<div className="pt-2 border-t border-[rgba(0,255,156,0.10)] font-mono text-[10px] text-[#5a6678]">
{loading ? "carregando…" : `${filteredPayload?.nodes.length ?? 0} nós · ${filteredPayload?.links.length ?? 0} arestas`}
<div className="mt-1 text-[9px]">Sigma.js + ForceAtlas2</div>
</div>
</div>
{/* RIGHT side panel — selected entity */}
{selectedNode && (
<div
className="absolute top-20 right-4 z-20 w-[340px] max-h-[calc(100vh-180px)] overflow-y-auto bg-[#0a121e]/95 backdrop-blur border-2 rounded p-4 space-y-4"
style={{ borderColor: CLASS_COLOR[selectedNode.entity_class] ?? "#7fdbff" }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div
className="font-mono text-[10px] uppercase tracking-widest mb-1"
style={{ color: CLASS_COLOR[selectedNode.entity_class] ?? "#7fdbff" }}
>
{CLASS_LABEL[selectedNode.entity_class] ?? selectedNode.entity_class}
</div>
<h3 className="font-mono text-base text-[#c8d4e6] font-bold leading-tight break-words">
{selectedNode.canonical_name}
</h3>
<div className="font-mono text-[10px] text-[#5a6678] mt-1 truncate">
{selectedNode.entity_id}
</div>
</div>
<button
onClick={() => setSelectedKey(null)}
className="text-[#5a6678] hover:text-[#ff6b6b] flex-shrink-0"
aria-label="fechar"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="px-3 py-2 bg-[#060a13] border border-[rgba(0,255,156,0.20)] rounded">
<div className="font-mono text-[9px] uppercase text-[#5a6678]">menções</div>
<div className="font-mono text-lg text-[#00ff9c] mt-0.5">{selectedNode.total_mentions}</div>
</div>
<div className="px-3 py-2 bg-[#060a13] border border-[rgba(127,219,255,0.20)] rounded">
<div className="font-mono text-[9px] uppercase text-[#5a6678]">documentos</div>
<div className="font-mono text-lg text-[#7fdbff] mt-0.5">{selectedNode.documents_count}</div>
</div>
</div>
<div className="space-y-1.5">
<Link
href={`/e/${CLASS_FOLDER[selectedNode.entity_class]}/${selectedNode.entity_id}`}
className="block w-full px-3 py-2 font-mono text-xs uppercase tracking-widest border-2 border-[#00ff9c] text-[#00ff9c] bg-[rgba(0,255,156,0.08)] hover:bg-[rgba(0,255,156,0.18)] rounded text-center"
>
abrir página completa
</Link>
<button
onClick={() => expand(selectedNode.entity_class, selectedNode.entity_id)}
className="block w-full px-3 py-2 font-mono text-xs uppercase tracking-widest border border-[#7fdbff] text-[#7fdbff] hover:bg-[rgba(127,219,255,0.10)] rounded"
>
+ expandir vizinhos
</button>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] mb-2">
{detailLoading ? "carregando vizinhos…" : `top vínculos (${detail?.neighbors.length ?? 0})`}
</div>
{detail?.neighbors && detail.neighbors.length > 0 ? (
<ul className="space-y-1">
{detail.neighbors.map((n) => {
const color = CLASS_COLOR[n.entity_class] ?? "#7fdbff";
return (
<li key={n.entity_pk}>
<button
onClick={() => {
// Focus the neighbor — if in payload, select; else inject
const inPayload = payload?.nodes.some((p) => p.entity_pk === n.entity_pk);
if (inPayload) {
setSelectedKey(String(n.entity_pk));
} else {
setPayload((prev) =>
prev
? {
nodes: [
...prev.nodes,
{
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,
},
],
links: [
...prev.links,
{
source: selectedNode.entity_pk,
target: n.entity_pk,
weight: n.weight,
},
],
}
: prev,
);
setTimeout(() => setSelectedKey(String(n.entity_pk)), 200);
}
}}
className="group w-full flex items-center gap-2 text-left p-1.5 -mx-1.5 rounded hover:bg-[rgba(0,255,156,0.04)]"
>
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ background: color }}
/>
<span className="text-[11px] text-[#c8d4e6] group-hover:text-[#00ff9c] flex-1 truncate">
{n.canonical_name}
</span>
<span className="font-mono text-[10px] text-[#5a6678] flex-shrink-0">
×{n.weight}
</span>
</button>
</li>
);
})}
</ul>
) : !detailLoading ? (
<p className="font-mono text-[10px] text-[#5a6678] italic">sem co-menções</p>
) : null}
</div>
</div>
)}
{/* Hover tooltip */}
{hoverKey && payload && (() => {
const n = payload.nodes.find((nn) => String(nn.entity_pk) === hoverKey);
if (!n) return null;
return (
<div
className="absolute top-20 right-4 z-10 pointer-events-none px-3 py-2 bg-[#0a121e] border-2 rounded text-xs font-mono"
style={{ borderColor: CLASS_COLOR[n.entity_class] ?? "#7fdbff" }}
>
<div className="text-[#c8d4e6] font-bold">{n.canonical_name}</div>
<div className="text-[10px] text-[#5a6678]">
{CLASS_LABEL[n.entity_class]} · {n.total_mentions} menções · {n.documents_count} docs
</div>
<div className="text-[9px] text-[#7fdbff] mt-0.5">clique para detalhes</div>
</div>
);
})()}
{/* Sigma container */}
<SigmaContainer
style={{ width: "100%", height: "100%", background: "#040810" }}
settings={{
allowInvalidContainer: true,
renderEdgeLabels: false,
labelColor: { color: "#c8d4e6" },
labelSize: 13,
labelWeight: "500",
defaultEdgeColor: "#3a4a5e",
minCameraRatio: 0.05,
maxCameraRatio: 10,
labelDensity: 0.07,
labelGridCellSize: 100,
labelRenderedSizeThreshold: 6,
}}
>
{filteredPayload && (
<GraphLoader
payload={filteredPayload}
visibleClasses={selectedClasses}
hoverNodeKey={hoverKey}
selectedNodeKey={selectedKey}
onSelect={setSelectedKey}
onHover={setHoverKey}
/>
)}
</SigmaContainer>
</div>
);
}