596 lines
24 KiB
TypeScript
596 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>
|
||
);
|
||
}
|