import { lazy, Suspense } from "react"; import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { Array as A, Order } from "effect"; import { Rotate3d, Search, Loader2, X, Filter, RefreshCw, } from "lucide-react"; import { cn } from "@/lib/utils"; import { flowIdAtom, graphTriplesAtom, graphViewAtom, resultData, resultError, resultLoading, settingsAtom, } from "@/atoms/workbench"; import type { Triple } from "@trustgraph/client"; import type { GraphNode, GraphLink, } from "@/lib/graph-utils"; import { localName, triplesToGraph, RDFS_LABEL, RDF_TYPE, termValue, directedGraphLinkProps, DEFAULT_GRAPH_NODE_COLOR, } from "@/lib/graph-utils"; import type { ForceGraphProps } from "react-force-graph-2d"; import { Badge } from "@/components/ui/badge"; const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType>; function NodeDetailPanel({ nodeId, label, triples, labelMap, onClose, }: { nodeId: string; label: string; triples: Triple[]; labelMap: Map; onClose: () => void; }) { const outbound: { predicate: string; object: string; objectLabel: string }[] = []; const inbound: { predicate: string; subject: string; subjectLabel: string }[] = []; for (const triple of triples) { const subject = termValue(triple.s); const predicate = termValue(triple.p); const object = termValue(triple.o); if (predicate === RDFS_LABEL || predicate === RDF_TYPE) continue; if (subject === nodeId) { outbound.push({ predicate: labelMap.get(predicate) ?? localName(predicate), object, objectLabel: labelMap.get(object) ?? localName(object), }); } if (object === nodeId) { inbound.push({ predicate: labelMap.get(predicate) ?? localName(predicate), subject, subjectLabel: labelMap.get(subject) ?? localName(subject), }); } } return ( ); } function paintNode(showLabels: boolean) { return (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.2); const x = node.x ?? 0; const y = node.y ?? 0; ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI); ctx.fillStyle = node.color ?? DEFAULT_GRAPH_NODE_COLOR; ctx.fill(); if (!showLabels || globalScale < 0.7) return; const fontSize = Math.max(10 / globalScale, 2); ctx.font = `${fontSize}px Inter, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; const light = document.documentElement.classList.contains("light"); ctx.fillStyle = light ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)"; ctx.fillText(node.label, x, y + radius + 1); }; } function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) { if (globalScale < 1.5) return; const source = link.source as unknown as GraphNode; const target = link.target as unknown as GraphNode; if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return; const midX = (source.x + target.x) / 2; const midY = (source.y + target.y) / 2; const fontSize = Math.max(8 / globalScale, 2); ctx.font = `${fontSize}px Inter, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = "rgba(161,161,170,0.65)"; ctx.fillText(link.label, midX, midY); } export default function GraphPage() { const flowId = useAtomValue(flowIdAtom); const collection = useAtomValue(settingsAtom).collection; const [view, setView] = useAtom(graphViewAtom); const triplesResult = useAtomValue(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit })); const refresh = useAtomRefresh(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit })); const triples = resultData(triplesResult, []); const loading = resultLoading(triplesResult, triples); const error = resultError(triplesResult); const { data, labelMap, typeMap } = triplesToGraph(triples); const search = view.searchTerm.trim().toLowerCase(); const graphData = search.length === 0 ? data : (() => { const nodes = data.nodes.filter((node) => node.label.toLowerCase().includes(search) || node.id.toLowerCase().includes(search)); const nodeIds = new Set(nodes.map((node) => node.id)); return { nodes, links: data.links.filter((link) => { const source = typeof link.source === "string" ? link.source : (link.source as GraphNode).id; const target = typeof link.target === "string" ? link.target : (link.target as GraphNode).id; return nodeIds.has(source) && nodeIds.has(target); }), }; })(); const selectedNode = view.selectedNodeId !== null ? data.nodes.find((node) => node.id === view.selectedNodeId) : undefined; const uniqueTypes = A.sort(Array.from(new Set(Array.from(typeMap.values()).map(localName))), Order.String); return (

Graph

{graphData.nodes.length} nodes {graphData.links.length} edges
setView({ ...view, searchTerm: event.target.value })} placeholder="Search graph..." className="w-48 bg-transparent text-sm text-fg placeholder:text-fg-subtle focus:outline-none" />
{error !== null && (

{error}

)}
{uniqueTypes.slice(0, 8).map((type) => {type})}
{loading && triples.length === 0 && (
Loading graph...
)} {!loading && graphData.nodes.length === 0 && (

No graph triples available.

)} {graphData.nodes.length > 0 && ( Loading graph renderer...
}> "after"} linkCanvasObject={paintLink} {...directedGraphLinkProps} nodePointerAreaPaint={(node, color, ctx) => { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(node.x ?? 0, node.y ?? 0, Math.max(6, Math.sqrt(node.degree + 1) * 3), 0, 2 * Math.PI); ctx.fill(); }} onNodeClick={(node) => setView({ ...view, selectedNodeId: node.id, selectedNodeLabel: node.label })} /> )} {selectedNode !== undefined && view.selectedNodeId !== null && ( setView({ ...view, selectedNodeId: null, selectedNodeLabel: null })} /> )}
); } function RefreshCwIcon({ loading }: { loading: boolean }) { return loading ? : ; }