Files
boat/src/components/Graph.tsx
Maxime Van Hees 4d024a39f4 first MVP
2025-11-14 21:07:10 +01:00

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