"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(null); const cyRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [selectedEdge, setSelectedEdge] = useState(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 (
Global Graph
Details
{!selectedNode && !selectedEdge && (
Tap a node or edge to see details.
)} {selectedNode && (
Person
{selectedNode.label}
{selectedNode.company || selectedNode.role ? (
{(selectedNode.role ?? "")} {selectedNode.company ? `@ ${selectedNode.company}` : ""}
) : null} {selectedNode.sectors?.length ? (
Sectors:{" "} {selectedNode.sectors.join(", ")}
) : null}
)} {selectedEdge && (
Connection
{selectedEdge.source} — {selectedEdge.target}
Provenance length:{" "} {selectedEdge.introducedByCount}
Has provenance:{" "} {selectedEdge.hasProvenance ? "Yes" : "No"}
)}
); }