751 lines
27 KiB
TypeScript
751 lines
27 KiB
TypeScript
/**
|
||
* 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 nó
|
||
</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]">5–9</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]">3–4</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>
|
||
);
|
||
}
|