752 lines
27 KiB
TypeScript
752 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>
|
|||
|
|
);
|
|||
|
|
}
|