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

751 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}