260 lines
7.0 KiB
TypeScript
260 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import cytoscape, { Core } from "cytoscape";
|
|
|
|
type GraphNodeDTO = {
|
|
id: string;
|
|
label: string;
|
|
sectors: string[];
|
|
company: string | null;
|
|
role: string | null;
|
|
};
|
|
|
|
type GraphEdgeDTO = {
|
|
id: string;
|
|
source: string;
|
|
target: string;
|
|
introducedByCount: number;
|
|
hasProvenance: boolean;
|
|
};
|
|
|
|
type GraphData = {
|
|
nodes: GraphNodeDTO[];
|
|
edges: GraphEdgeDTO[];
|
|
};
|
|
|
|
type Props = {
|
|
data: GraphData;
|
|
height?: number | string;
|
|
};
|
|
|
|
export default function Graph({ data, height = 600 }: Props) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const cyRef = useRef<Core | null>(null);
|
|
|
|
const [selectedNode, setSelectedNode] = useState<GraphNodeDTO | null>(null);
|
|
const [selectedEdge, setSelectedEdge] = useState<GraphEdgeDTO | null>(null);
|
|
|
|
const elements = useMemo(() => {
|
|
const nodes = data.nodes.map((n) => ({
|
|
data: {
|
|
id: n.id,
|
|
label: n.label,
|
|
company: n.company,
|
|
role: n.role,
|
|
sectors: n.sectors,
|
|
},
|
|
}));
|
|
const edges = data.edges.map((e) => ({
|
|
data: {
|
|
id: e.id,
|
|
source: e.source,
|
|
target: e.target,
|
|
introducedByCount: e.introducedByCount,
|
|
hasProvenance: e.hasProvenance,
|
|
},
|
|
}));
|
|
return { nodes, edges };
|
|
}, [data]);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
// Destroy previous instance if any
|
|
if (cyRef.current) {
|
|
cyRef.current.destroy();
|
|
cyRef.current = null;
|
|
}
|
|
|
|
const cy = cytoscape({
|
|
container: containerRef.current,
|
|
elements: {
|
|
nodes: elements.nodes,
|
|
edges: elements.edges,
|
|
},
|
|
style: [
|
|
{
|
|
selector: "node",
|
|
style: {
|
|
"background-color": "#0ea5e9", // sky-500
|
|
"border-width": 2,
|
|
"border-color": "#0369a1", // sky-700
|
|
label: "data(label)",
|
|
"font-size": 10,
|
|
"text-valign": "center",
|
|
"text-halign": "center",
|
|
color: "#111827", // gray-900
|
|
"text-wrap": "wrap",
|
|
"text-max-width": "90px",
|
|
},
|
|
},
|
|
{
|
|
selector: "edge",
|
|
style: {
|
|
width: 2,
|
|
"line-color": "#94a3b8", // slate-400
|
|
opacity: 0.9,
|
|
"curve-style": "bezier",
|
|
},
|
|
},
|
|
{
|
|
selector: "edge[hasProvenance]",
|
|
style: {
|
|
"line-color": "#16a34a", // green-600
|
|
},
|
|
},
|
|
{
|
|
selector: ":selected",
|
|
style: {
|
|
"border-width": 3,
|
|
"border-color": "#111827",
|
|
},
|
|
},
|
|
],
|
|
layout: {
|
|
name: "cose",
|
|
animate: true,
|
|
padding: 30,
|
|
} as any,
|
|
wheelSensitivity: 0.25,
|
|
});
|
|
|
|
// Fit initially
|
|
cy.one("render", () => {
|
|
cy.fit(undefined, 30);
|
|
});
|
|
|
|
// Handlers
|
|
const onNodeSelect = (evt: any) => {
|
|
const d = evt.target.data();
|
|
setSelectedEdge(null);
|
|
setSelectedNode({
|
|
id: d.id,
|
|
label: d.label,
|
|
company: d.company ?? null,
|
|
role: d.role ?? null,
|
|
sectors: (d.sectors as string[]) ?? [],
|
|
});
|
|
};
|
|
|
|
const onEdgeSelect = (evt: any) => {
|
|
const d = evt.target.data();
|
|
setSelectedNode(null);
|
|
setSelectedEdge({
|
|
id: d.id,
|
|
source: d.source,
|
|
target: d.target,
|
|
introducedByCount: Number(d.introducedByCount ?? 0),
|
|
hasProvenance: Boolean(d.hasProvenance),
|
|
});
|
|
};
|
|
|
|
cy.on("tap", "node", onNodeSelect);
|
|
cy.on("tap", "edge", onEdgeSelect);
|
|
cy.on("tap", (evt) => {
|
|
if (evt.target === cy) {
|
|
setSelectedNode(null);
|
|
setSelectedEdge(null);
|
|
}
|
|
});
|
|
|
|
cyRef.current = cy;
|
|
|
|
return () => {
|
|
cy.off("tap", "node", onNodeSelect);
|
|
cy.off("tap", "edge", onEdgeSelect);
|
|
if (cyRef.current) {
|
|
cyRef.current.destroy();
|
|
cyRef.current = null;
|
|
}
|
|
};
|
|
}, [elements]);
|
|
|
|
const refit = () => {
|
|
cyRef.current?.fit(undefined, 30);
|
|
};
|
|
|
|
const relayout = () => {
|
|
if (!cyRef.current) return;
|
|
const layout = cyRef.current.elements().layout({ name: "cose", animate: true, padding: 30 } as any);
|
|
layout.run();
|
|
};
|
|
|
|
return (
|
|
<div className="flex gap-4">
|
|
<div className="flex-1 rounded border border-zinc-200 bg-white text-zinc-900 dark:text-zinc-900">
|
|
<div className="flex items-center gap-2 border-b border-zinc-200 p-2">
|
|
<span className="font-medium">Global Graph</span>
|
|
<div className="ml-auto flex gap-2">
|
|
<button
|
|
onClick={refit}
|
|
className="rounded border border-zinc-300 px-2 py-1 text-sm hover:bg-zinc-50"
|
|
type="button"
|
|
>
|
|
Fit
|
|
</button>
|
|
<button
|
|
onClick={relayout}
|
|
className="rounded border border-zinc-300 px-2 py-1 text-sm hover:bg-zinc-50"
|
|
type="button"
|
|
>
|
|
Layout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={containerRef}
|
|
style={{ height, width: "100%" }}
|
|
className="cytoscape-container"
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-80 shrink-0 rounded border border-zinc-200 bg-white p-3 text-zinc-900 dark:text-zinc-900">
|
|
<div className="mb-2 text-sm font-semibold">Details</div>
|
|
{!selectedNode && !selectedEdge && (
|
|
<div className="text-sm text-zinc-500">Tap a node or edge to see details.</div>
|
|
)}
|
|
|
|
{selectedNode && (
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">Person</div>
|
|
<div className="font-medium">{selectedNode.label}</div>
|
|
</div>
|
|
{selectedNode.company || selectedNode.role ? (
|
|
<div className="text-sm">
|
|
{(selectedNode.role ?? "")} {selectedNode.company ? `@ ${selectedNode.company}` : ""}
|
|
</div>
|
|
) : null}
|
|
{selectedNode.sectors?.length ? (
|
|
<div className="text-sm">
|
|
<span className="text-xs text-zinc-500">Sectors:</span>{" "}
|
|
{selectedNode.sectors.join(", ")}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{selectedEdge && (
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">Connection</div>
|
|
<div className="text-sm">
|
|
{selectedEdge.source} — {selectedEdge.target}
|
|
</div>
|
|
</div>
|
|
<div className="text-sm">
|
|
<span className="text-xs text-zinc-500">Provenance length:</span>{" "}
|
|
{selectedEdge.introducedByCount}
|
|
</div>
|
|
<div className="text-sm">
|
|
<span className="text-xs text-zinc-500">Has provenance:</span>{" "}
|
|
{selectedEdge.hasProvenance ? "Yes" : "No"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |