597 lines
24 KiB
TypeScript
597 lines
24 KiB
TypeScript
|
|
/**
|
|||
|
|
* ForceGraphCanvas — D3 force-directed entity graph (Obsidian-style).
|
|||
|
|
*
|
|||
|
|
* Layout:
|
|||
|
|
* - Left sidebar: filters (classes, limit) — sempre visível, fora do canvas
|
|||
|
|
* - Right side panel: detalhe da entidade selecionada (quando clica num nó)
|
|||
|
|
* - Center: canvas fullscreen com nodes coloridos por classe + edges
|
|||
|
|
* coloridas por peso (low=cinza, mid=cyan, high=verde)
|
|||
|
|
*
|
|||
|
|
* Interação:
|
|||
|
|
* - HOVER: tooltip flutuante com nome + classe + mentions
|
|||
|
|
* - CLICK: abre side panel direito com info da entidade + top neighbors + botão "abrir página"
|
|||
|
|
* - DOUBLE-CLICK: navega direto para /e/<class>/<id>
|
|||
|
|
* - Scroll: zoom; drag canvas: pan
|
|||
|
|
*/
|
|||
|
|
"use client";
|
|||
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||
|
|
import dynamic from "next/dynamic";
|
|||
|
|
import Link from "next/link";
|
|||
|
|
|
|||
|
|
const ForceGraph2D = dynamic(() => import("react-force-graph-2d"), { ssr: false });
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
interface GraphNode extends RawNode {
|
|||
|
|
id: number;
|
|||
|
|
x?: number;
|
|||
|
|
y?: number;
|
|||
|
|
vx?: number;
|
|||
|
|
vy?: number;
|
|||
|
|
}
|
|||
|
|
interface GraphLink {
|
|||
|
|
source: number | GraphNode;
|
|||
|
|
target: number | GraphNode;
|
|||
|
|
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"];
|
|||
|
|
|
|||
|
|
/** Color edge by weight tier — visual diferenciação por intensidade */
|
|||
|
|
function edgeColor(weight: number): string {
|
|||
|
|
if (weight >= 10) return "rgba(0,255,156,0.55)"; // strong: green
|
|||
|
|
if (weight >= 5) return "rgba(127,219,255,0.45)"; // medium: cyan
|
|||
|
|
if (weight >= 3) return "rgba(167,139,250,0.35)"; // mild: purple
|
|||
|
|
return "rgba(127,219,255,0.18)"; // weak: faded cyan
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function edgeWidth(weight: number): number {
|
|||
|
|
return Math.max(0.5, Math.min(6, Math.log2(weight + 1) * 1.2));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface EntityDetail {
|
|||
|
|
entity_pk: number;
|
|||
|
|
entity_class: string;
|
|||
|
|
entity_id: string;
|
|||
|
|
canonical_name: 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<number, EntityDetail>();
|
|||
|
|
|
|||
|
|
interface ForceGraph2DRef {
|
|||
|
|
d3Force: (name: string) => { strength?: (v: number) => unknown; distance?: (v: number) => unknown } | null;
|
|||
|
|
d3ReheatSimulation: () => void;
|
|||
|
|
zoomToFit: (durationMs?: number, paddingPx?: number) => void;
|
|||
|
|
centerAt: (x?: number, y?: number, durationMs?: number) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ForceGraphCanvas() {
|
|||
|
|
const fgRef = useRef<ForceGraph2DRef | null>(null);
|
|||
|
|
const [nodes, setNodes] = useState<GraphNode[]>([]);
|
|||
|
|
const [links, setLinks] = useState<GraphLink[]>([]);
|
|||
|
|
const [selectedClasses, setSelectedClasses] = useState<Set<string>>(new Set(ALL_CLASSES));
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [hoverNode, setHoverNode] = useState<GraphNode | null>(null);
|
|||
|
|
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null);
|
|||
|
|
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
|||
|
|
const [detail, setDetail] = useState<EntityDetail | null>(null);
|
|||
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|||
|
|
const [limit, setLimit] = useState(40);
|
|||
|
|
const [minWeight, setMinWeight] = useState(3);
|
|||
|
|
const [search, setSearch] = useState("");
|
|||
|
|
|
|||
|
|
// Tune d3-force after the graph mounts and on data change — STRONGER repulsion + LONGER links
|
|||
|
|
useEffect(() => {
|
|||
|
|
const fg = fgRef.current;
|
|||
|
|
if (!fg) return;
|
|||
|
|
const charge = fg.d3Force("charge");
|
|||
|
|
if (charge?.strength) charge.strength(-450);
|
|||
|
|
const link = fg.d3Force("link");
|
|||
|
|
if (link?.distance) link.distance(120);
|
|||
|
|
const center = fg.d3Force("center");
|
|||
|
|
if (center?.strength) center.strength(0.04);
|
|||
|
|
fg.d3ReheatSimulation();
|
|||
|
|
setTimeout(() => fg.zoomToFit?.(800, 80), 1500);
|
|||
|
|
}, [nodes.length, links.length]);
|
|||
|
|
|
|||
|
|
// Initial seed load — re-runs when filters change
|
|||
|
|
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[] }) => {
|
|||
|
|
const ns = (data.nodes ?? []).map((n) => ({ ...n, id: n.entity_pk } as GraphNode));
|
|||
|
|
const ls = (data.links ?? []).map((l) => ({ source: l.source, target: l.target, weight: l.weight } as GraphLink));
|
|||
|
|
setNodes(ns);
|
|||
|
|
setLinks(ls);
|
|||
|
|
setLoading(false);
|
|||
|
|
})
|
|||
|
|
.catch(() => setLoading(false));
|
|||
|
|
}, [limit, minWeight, selectedClasses]);
|
|||
|
|
|
|||
|
|
// Fetch detail when node selected
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!selectedNode) {
|
|||
|
|
setDetail(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const cached = detailCache.get(selectedNode.entity_pk);
|
|||
|
|
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 = {
|
|||
|
|
entity_pk: selectedNode.entity_pk,
|
|||
|
|
entity_class: selectedNode.entity_class,
|
|||
|
|
entity_id: selectedNode.entity_id,
|
|||
|
|
canonical_name: selectedNode.canonical_name,
|
|||
|
|
total_mentions: data.entity?.total_mentions ?? selectedNode.total_mentions,
|
|||
|
|
documents_count: data.entity?.documents_count ?? selectedNode.documents_count,
|
|||
|
|
neighbors: data.neighbors ?? [],
|
|||
|
|
};
|
|||
|
|
detailCache.set(selectedNode.entity_pk, d);
|
|||
|
|
setDetail(d);
|
|||
|
|
})
|
|||
|
|
.catch(() => setDetail(null))
|
|||
|
|
.finally(() => setDetailLoading(false));
|
|||
|
|
}, [selectedNode]);
|
|||
|
|
|
|||
|
|
const onNodeClick = useCallback(async (node: GraphNode) => {
|
|||
|
|
setSelectedNode(node);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const expandNode = useCallback(
|
|||
|
|
async (node: GraphNode) => {
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(
|
|||
|
|
`/api/graph?op=neighbors&class=${node.entity_class}&id=${encodeURIComponent(node.entity_id)}&limit=15`,
|
|||
|
|
);
|
|||
|
|
if (!r.ok) return;
|
|||
|
|
const data = (await r.json()) as { neighbors?: Array<RawNode & { weight: number }> };
|
|||
|
|
if (!data.neighbors) return;
|
|||
|
|
setNodes((prev) => {
|
|||
|
|
const existing = new Set(prev.map((p) => p.id));
|
|||
|
|
const additions = data.neighbors!
|
|||
|
|
.filter((n) => !existing.has(n.entity_pk))
|
|||
|
|
.map((n) => ({ ...n, id: n.entity_pk } as GraphNode));
|
|||
|
|
return [...prev, ...additions];
|
|||
|
|
});
|
|||
|
|
setLinks((prev) => {
|
|||
|
|
const seen = new Set(
|
|||
|
|
prev.map((l) => {
|
|||
|
|
const s = typeof l.source === "object" ? (l.source as GraphNode).id : l.source;
|
|||
|
|
const t = typeof l.target === "object" ? (l.target as GraphNode).id : l.target;
|
|||
|
|
return `${Math.min(s, t)}-${Math.max(s, t)}`;
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
const additions: GraphLink[] = [];
|
|||
|
|
for (const n of data.neighbors!) {
|
|||
|
|
const a = node.entity_pk;
|
|||
|
|
const b = n.entity_pk;
|
|||
|
|
const key = `${Math.min(a, b)}-${Math.max(a, b)}`;
|
|||
|
|
if (!seen.has(key)) {
|
|||
|
|
additions.push({ source: a, target: b, weight: n.weight });
|
|||
|
|
seen.add(key);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return [...prev, ...additions];
|
|||
|
|
});
|
|||
|
|
} 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;
|
|||
|
|
});
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const visibleData = useMemo(() => {
|
|||
|
|
let filteredNodes = nodes.filter((n) => selectedClasses.has(n.entity_class));
|
|||
|
|
if (search.trim()) {
|
|||
|
|
const sl = search.toLowerCase();
|
|||
|
|
filteredNodes = filteredNodes.filter((n) =>
|
|||
|
|
n.canonical_name.toLowerCase().includes(sl) || n.entity_id.toLowerCase().includes(sl),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const allowed = new Set(filteredNodes.map((n) => n.id));
|
|||
|
|
const filteredLinks = links.filter((l) => {
|
|||
|
|
const s = typeof l.source === "object" ? (l.source as GraphNode).id : l.source;
|
|||
|
|
const t = typeof l.target === "object" ? (l.target as GraphNode).id : l.target;
|
|||
|
|
return allowed.has(s) && allowed.has(t);
|
|||
|
|
});
|
|||
|
|
return { nodes: filteredNodes, links: filteredLinks };
|
|||
|
|
}, [nodes, links, selectedClasses, search]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="relative w-full h-full bg-[#040810] overflow-hidden">
|
|||
|
|
{/* LEFT sidebar — filters (sempre visível, fora do z-30 do page header) */}
|
|||
|
|
<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].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">
|
|||
|
|
{[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: "rgba(0,255,156,0.55)" }} />
|
|||
|
|
<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: "rgba(127,219,255,0.45)" }} />
|
|||
|
|
<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: "rgba(167,139,250,0.35)" }} />
|
|||
|
|
<span className="text-[#8896aa]">3–4</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="inline-block w-6 h-0.5" style={{ background: "rgba(127,219,255,0.18)" }} />
|
|||
|
|
<span className="text-[#8896aa]">2 (mín.)</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…" : `${visibleData.nodes.length} nós · ${visibleData.links.length} arestas`}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* RIGHT side panel — entidade selecionada */}
|
|||
|
|
{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={() => setSelectedNode(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>
|
|||
|
|
|
|||
|
|
{/* Action buttons */}
|
|||
|
|
<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={() => expandNode(selectedNode)}
|
|||
|
|
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 no grafo
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Neighbors list */}
|
|||
|
|
<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={() => {
|
|||
|
|
const folded = nodes.find((nn) => nn.entity_pk === n.entity_pk);
|
|||
|
|
if (folded) {
|
|||
|
|
setSelectedNode(folded);
|
|||
|
|
} else {
|
|||
|
|
// Inject into graph + select
|
|||
|
|
const newNode: GraphNode = {
|
|||
|
|
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,
|
|||
|
|
id: n.entity_pk,
|
|||
|
|
};
|
|||
|
|
setNodes((prev) => [...prev, newNode]);
|
|||
|
|
setLinks((prev) => [...prev, { source: selectedNode.entity_pk, target: n.entity_pk, weight: n.weight }]);
|
|||
|
|
setSelectedNode(newNode);
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
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>
|
|||
|
|
|
|||
|
|
<p className="font-mono text-[9px] text-[#5a6678] pt-2 border-t border-[rgba(0,255,156,0.10)]">
|
|||
|
|
duplo-clique no nó: abre página da entidade · clique vizinho: foca nele
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Hover tooltip — segue o mouse */}
|
|||
|
|
{hoverNode && hoverPos && (
|
|||
|
|
<div
|
|||
|
|
className="absolute z-30 pointer-events-none px-2.5 py-1.5 bg-[#0a121e] border-2 rounded text-xs font-mono"
|
|||
|
|
style={{
|
|||
|
|
left: hoverPos.x + 12,
|
|||
|
|
top: hoverPos.y + 12,
|
|||
|
|
borderColor: CLASS_COLOR[hoverNode.entity_class] ?? "#7fdbff",
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<div className="text-[#c8d4e6] font-bold">{hoverNode.canonical_name}</div>
|
|||
|
|
<div className="text-[10px] text-[#5a6678]">
|
|||
|
|
{hoverNode.entity_class} · {hoverNode.total_mentions} menções · {hoverNode.documents_count} docs
|
|||
|
|
</div>
|
|||
|
|
<div className="text-[9px] text-[#7fdbff] mt-0.5">clique para detalhes</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Canvas */}
|
|||
|
|
<ForceGraph2D
|
|||
|
|
ref={fgRef as never}
|
|||
|
|
graphData={visibleData as never}
|
|||
|
|
backgroundColor="#040810"
|
|||
|
|
nodeRelSize={2.5}
|
|||
|
|
nodeVal={(n) => Math.max(1.5, Math.log2((n as GraphNode).total_mentions + 2) * 0.8)}
|
|||
|
|
nodeColor={(n) => CLASS_COLOR[(n as GraphNode).entity_class] ?? "#7fdbff"}
|
|||
|
|
nodeLabel={() => ""}
|
|||
|
|
linkColor={(l) => edgeColor((l as GraphLink).weight)}
|
|||
|
|
linkWidth={(l) => edgeWidth((l as GraphLink).weight)}
|
|||
|
|
// Physics — separa nós com força + arestas mais longas
|
|||
|
|
d3VelocityDecay={0.3}
|
|||
|
|
d3AlphaDecay={0.015}
|
|||
|
|
cooldownTicks={250}
|
|||
|
|
warmupTicks={60}
|
|||
|
|
// Use d3-force charge customization via dagMode? No, lib has limited API; rely on defaults + manual.
|
|||
|
|
onNodeClick={onNodeClick as never}
|
|||
|
|
onNodeHover={(n) => {
|
|||
|
|
setHoverNode(n as GraphNode | null);
|
|||
|
|
if (!n) setHoverPos(null);
|
|||
|
|
}}
|
|||
|
|
onBackgroundClick={() => setSelectedNode(null)}
|
|||
|
|
nodeCanvasObjectMode={() => "after"}
|
|||
|
|
nodeCanvasObject={(n, ctx, scale) => {
|
|||
|
|
const node = n as GraphNode;
|
|||
|
|
const isSelected = selectedNode?.entity_pk === node.entity_pk;
|
|||
|
|
const isHovered = hoverNode?.entity_pk === node.entity_pk;
|
|||
|
|
// Anti-clutter: with many nodes, mostrar label só:
|
|||
|
|
// - quando zoomado (scale ≥ 1.5)
|
|||
|
|
// - OU é hub (>200 mentions)
|
|||
|
|
// - OU é hover/selected
|
|||
|
|
const isHub = node.total_mentions >= 200;
|
|||
|
|
const showLabel = isSelected || isHovered || isHub || scale >= 1.5;
|
|||
|
|
if (!showLabel) {
|
|||
|
|
if (isSelected) {
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.arc(node.x ?? 0, node.y ?? 0, 10 / scale, 0, 2 * Math.PI, false);
|
|||
|
|
ctx.strokeStyle = "#00ff9c";
|
|||
|
|
ctx.lineWidth = 2.5 / scale;
|
|||
|
|
ctx.stroke();
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const fontSize = Math.max(10, 14 / scale);
|
|||
|
|
ctx.font = `${isHub ? "bold " : ""}${fontSize}px sans-serif`;
|
|||
|
|
const label =
|
|||
|
|
node.canonical_name.length > 28
|
|||
|
|
? node.canonical_name.slice(0, 26) + "…"
|
|||
|
|
: node.canonical_name;
|
|||
|
|
const tw = ctx.measureText(label).width;
|
|||
|
|
const pad = 4 / scale;
|
|||
|
|
// Background pill behind text — readability
|
|||
|
|
ctx.fillStyle = isSelected ? "rgba(0,255,156,0.85)" : "rgba(10,18,30,0.85)";
|
|||
|
|
ctx.fillRect(
|
|||
|
|
(node.x ?? 0) - tw / 2 - pad,
|
|||
|
|
(node.y ?? 0) + 8 / scale,
|
|||
|
|
tw + pad * 2,
|
|||
|
|
fontSize + pad,
|
|||
|
|
);
|
|||
|
|
ctx.fillStyle = isSelected ? "#040810" : "#c8d4e6";
|
|||
|
|
ctx.textAlign = "center";
|
|||
|
|
ctx.textBaseline = "top";
|
|||
|
|
ctx.fillText(label, node.x ?? 0, (node.y ?? 0) + 8 / scale + pad / 2);
|
|||
|
|
// Selected ring
|
|||
|
|
if (isSelected) {
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.arc(node.x ?? 0, node.y ?? 0, 10 / scale, 0, 2 * Math.PI, false);
|
|||
|
|
ctx.strokeStyle = "#00ff9c";
|
|||
|
|
ctx.lineWidth = 2.5 / scale;
|
|||
|
|
ctx.stroke();
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|