disclosure-bureau/web/components/force-graph-canvas.tsx

597 lines
24 KiB
TypeScript
Raw Normal View History

/**
* 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 )
* - 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
</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]">59</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]">34</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 : 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>
);
}