first MVP
This commit is contained in:
260
src/components/Graph.tsx
Normal file
260
src/components/Graph.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user