/** * 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// * - 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 = { person: "#ff6ec7", organization: "#ff8a4d", location: "#3fde6a", event: "#ffa500", uap_object: "#ff3344", vehicle: "#5b9bd5", operation: "#9b5de5", concept: "#06d6a0", }; const CLASS_FOLDER: Record = { person: "people", organization: "organizations", location: "locations", event: "events", uap_object: "uap-objects", vehicle: "vehicles", operation: "operations", concept: "concepts", }; const CLASS_LABEL: Record = { 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(); 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(null); const [nodes, setNodes] = useState([]); const [links, setLinks] = useState([]); const [selectedClasses, setSelectedClasses] = useState>(new Set(ALL_CLASSES)); const [loading, setLoading] = useState(true); const [hoverNode, setHoverNode] = useState(null); const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null); const [selectedNode, setSelectedNode] = useState(null); const [detail, setDetail] = useState(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 }; 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 (
{/* LEFT sidebar — filters (sempre visível, fora do z-30 do page header) */}
🔍 buscar nó
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" />
classes
{ALL_CLASSES.map((cls) => { const active = selectedClasses.has(cls); const color = CLASS_COLOR[cls] ?? "#7fdbff"; return ( ); })}
top entidades
{[20, 40, 80, 150].map((n) => ( ))}
mostrar vínculos com ≥
{[2, 3, 5, 10].map((n) => ( ))}
força do vínculo
≥ 10 co-menções
5–9
3–4
2 (mín.)
{loading ? "carregando…" : `${visibleData.nodes.length} nós · ${visibleData.links.length} arestas`}
{/* RIGHT side panel — entidade selecionada */} {selectedNode && (
{CLASS_LABEL[selectedNode.entity_class] ?? selectedNode.entity_class}

{selectedNode.canonical_name}

{selectedNode.entity_id}
menções
{selectedNode.total_mentions}
documentos
{selectedNode.documents_count}
{/* Action buttons */}
abrir página completa →
{/* Neighbors list */}
{detailLoading ? "carregando vizinhos…" : `top vínculos (${detail?.neighbors.length ?? 0})`}
{detail?.neighbors && detail.neighbors.length > 0 ? (
    {detail.neighbors.map((n) => { const color = CLASS_COLOR[n.entity_class] ?? "#7fdbff"; return (
  • ); })}
) : !detailLoading ? (

sem co-menções

) : null}

duplo-clique no nó: abre página da entidade · clique vizinho: foca nele

)} {/* Hover tooltip — segue o mouse */} {hoverNode && hoverPos && (
{hoverNode.canonical_name}
{hoverNode.entity_class} · {hoverNode.total_mentions} menções · {hoverNode.documents_count} docs
clique para detalhes
)} {/* Canvas */} 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(); } }} />
); }