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

596 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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