"use client"; import { useEffect, useMemo } from "react"; import dagre from "dagre"; import { motion, AnimatePresence } from "framer-motion"; import { X, TreeStructure } from "@phosphor-icons/react"; import { ReactFlow, ReactFlowProvider, Background, Controls, MiniMap, useNodesState, useEdgesState, useReactFlow, Handle, Position, MarkerType, type Node, type Edge, type NodeTypes, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; type GraphNode = { id: string; type?: string; label?: string; [key: string]: unknown; }; type GraphEdge = { source: string; target: string; type?: string; }; type CertNodeData = { typeStr: string; detail: string; stroke: string; }; const NODE_W = 240; const NODE_H = 96; function nodeStroke(type: string): string { const t = type.toLowerCase(); if (t.includes("decision")) return "#34d399"; if (t.includes("certificate")) return "#a78bfa"; if (t.includes("risk") || t.includes("fraud")) return "#f87171"; if (t.includes("policy")) return "#38bdf8"; if (t.includes("evidence") || t.includes("artifact")) return "#fbbf24"; if (t.includes("vendor") || t.includes("invoice")) return "#94a3b8"; if (t.includes("case")) return "#e4e4e7"; return "#71717a"; } function layoutWithDagre( rfNodes: Node[], rfEdges: Edge[], ): { nodes: Node[]; edges: Edge[] } { const g = new dagre.graphlib.Graph(); g.setDefaultEdgeLabel(() => ({})); g.setGraph({ rankdir: "TB", align: "UL", nodesep: 56, ranksep: 72, marginx: 48, marginy: 48, acyclicer: "greedy", ranker: "network-simplex", }); rfNodes.forEach((n) => { g.setNode(n.id, { width: NODE_W, height: NODE_H }); }); rfEdges.forEach((e) => { if (g.hasNode(e.source) && g.hasNode(e.target)) { g.setEdge(e.source, e.target); } }); dagre.layout(g); const nodes = rfNodes.map((node) => { const pos = g.node(node.id); if (!pos || typeof pos.x !== "number") { return { ...node, position: { x: 0, y: 0 } }; } return { ...node, targetPosition: Position.Top, sourcePosition: Position.Bottom, position: { x: pos.x - NODE_W / 2, y: pos.y - NODE_H / 2, }, }; }); return { nodes, edges: rfEdges }; } function CertNode({ data, }: { data: CertNodeData; }) { return (

{data.typeStr}

{data.detail}

); } const nodeTypes: NodeTypes = { cert: CertNode, }; function GraphCanvas({ initialNodes, initialEdges, }: { initialNodes: Node[]; initialEdges: Edge[]; }) { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const { fitView } = useReactFlow(); useEffect(() => { setNodes(initialNodes); setEdges(initialEdges); }, [initialNodes, initialEdges, setNodes, setEdges]); useEffect(() => { const id = requestAnimationFrame(() => { fitView({ padding: 0.2, duration: 280, maxZoom: 1.25 }); }); return () => cancelAnimationFrame(id); }, [nodes, edges, fitView]); return (
{ const d = n.data as CertNodeData | undefined; return d?.stroke ?? "#52525b"; }} maskColor="rgba(0,0,0,0.55)" />
); } function buildFlowElements( rawNodes: GraphNode[], rawEdges: GraphEdge[], ): { nodes: Node[]; edges: Edge[] } { const nodeIds = new Set(rawNodes.map((n) => String(n.id))); const edgesIn = rawEdges.filter( (e) => nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)), ); const rfNodes: Node[] = rawNodes.map((n) => { const typeStr = String(n.type || "node"); const detail = String(n.label ?? n.id ?? typeStr); return { id: String(n.id), type: "cert", position: { x: 0, y: 0 }, data: { typeStr, detail, stroke: nodeStroke(typeStr), }, }; }); const rfEdges: Edge[] = edgesIn.map((e, i) => ({ id: `e-${String(e.source)}-${String(e.target)}-${i}`, source: String(e.source), target: String(e.target), label: String(e.type || "link"), labelStyle: { fill: "#d4d4d8", fontSize: 10, fontWeight: 500 }, labelBgStyle: { fill: "#18181b", fillOpacity: 0.92 }, labelBgPadding: [6, 4] as [number, number], labelBgBorderRadius: 4, })); return layoutWithDagre(rfNodes, rfEdges); } export function CertificateGraphModal({ open, onClose, graph, }: { open: boolean; onClose: () => void; graph: Record | null; }) { const { nodes, edges, title } = useMemo(() => { if (!graph || typeof graph !== "object") { return { nodes: [] as GraphNode[], edges: [] as GraphEdge[], title: "" }; } const rawNodes = graph.nodes; const rawEdges = graph.edges; const n = Array.isArray(rawNodes) ? (rawNodes as GraphNode[]) : []; const e = Array.isArray(rawEdges) ? (rawEdges as GraphEdge[]) : []; const t = (typeof graph.certificate_version === "string" && graph.certificate_version) || "Trust / certificate graph"; return { nodes: n.slice(0, 96), edges: e, title: t }; }, [graph]); const flow = useMemo( () => (nodes.length ? buildFlowElements(nodes, edges) : null), [nodes, edges], ); return ( {open && ( e.stopPropagation()} >

Decision certificate graph

{title} · {nodes.length} nodes · {edges.length} edges · drag to pan, scroll to zoom

{nodes.length === 0 ? (

No graph payload on this run. It appears after a graded{" "} submit_decision{" "} from the LedgerShield backend.

) : flow ? ( ) : null}
)}
); }