"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import type { Node, Edge } from "@xyflow/react"; import { ReactFlow, Background, Controls, MiniMap, Panel, useNodesState, useEdgesState, MarkerType, BackgroundVariant, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { fetchKnowledgeGraph } from "@/lib/api/knowledge"; import ELK from "elkjs/lib/elk.bundled.js"; // PDA-friendly status colors from CLAUDE.md const STATUS_COLORS = { PUBLISHED: "#10B981", // green-500 - Active DRAFT: "#3B82F6", // blue-500 - Scheduled/Upcoming ARCHIVED: "#9CA3AF", // gray-400 - Dormant } as const; const ORPHAN_COLOR = "#D1D5DB"; // gray-300 - Orphaned nodes type LayoutType = "force" | "hierarchical" | "circular"; interface GraphNode { id: string; slug: string; title: string; summary: string | null; status?: string; tags: { id: string; name: string; slug: string; color: string | null; }[]; depth: number; isOrphan?: boolean; } interface GraphEdge { id: string; sourceId: string; targetId: string; linkText: string; } interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; stats: { totalNodes: number; totalEdges: number; orphanCount: number; }; } interface KnowledgeGraphViewerProps { initialFilters?: { tags?: string[]; status?: string; limit?: number; }; } const elk = new ELK(); /** * Calculate node size based on number of connections */ function calculateNodeSize(connectionCount: number): number { const minSize = 40; const maxSize = 120; const size = minSize + Math.min(connectionCount * 8, maxSize - minSize); return size; } /** * Get node color based on status (PDA-friendly) */ function getNodeColor(node: GraphNode): string { if (node.isOrphan) { return ORPHAN_COLOR; } const status = node.status as keyof typeof STATUS_COLORS; return status in STATUS_COLORS ? STATUS_COLORS[status] : STATUS_COLORS.PUBLISHED; } /** * Calculate connection count for each node */ function calculateConnectionCounts(nodes: GraphNode[], edges: GraphEdge[]): Map { const counts = new Map(); // Initialize all nodes with 0 nodes.forEach((node) => counts.set(node.id, 0)); // Count connections edges.forEach((edge) => { counts.set(edge.sourceId, (counts.get(edge.sourceId) ?? 0) + 1); counts.set(edge.targetId, (counts.get(edge.targetId) ?? 0) + 1); }); return counts; } /** * Convert graph data to ReactFlow nodes */ function convertToReactFlowNodes( nodes: GraphNode[], edges: GraphEdge[], layout: LayoutType ): Node[] { const connectionCounts = calculateConnectionCounts(nodes, edges); return nodes.map((node, index) => { const connectionCount = connectionCounts.get(node.id) ?? 0; const size = calculateNodeSize(connectionCount); const color = getNodeColor(node); return { id: node.id, type: "default", position: getInitialPosition(index, nodes.length, layout), data: { label: node.title, slug: node.slug, summary: node.summary, status: node.status, tags: node.tags, connectionCount, isOrphan: node.isOrphan, }, style: { width: size, height: size, backgroundColor: color, color: "#FFFFFF", border: "2px solid #FFFFFF", borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", padding: "8px", fontSize: Math.max(10, size / 8), fontWeight: 600, textAlign: "center", cursor: "pointer", boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", }, }; }); } /** * Convert graph data to ReactFlow edges */ function convertToReactFlowEdges(edges: GraphEdge[]): Edge[] { return edges.map((edge) => ({ id: edge.id, source: edge.sourceId, target: edge.targetId, type: "smoothstep", animated: false, markerEnd: { type: MarkerType.ArrowClosed, width: 20, height: 20, }, style: { strokeWidth: 2, stroke: "#94A3B8", // slate-400 }, label: edge.linkText, labelStyle: { fontSize: 10, fill: "#64748B", // slate-500 }, })); } /** * Get initial position based on layout type */ function getInitialPosition( index: number, totalNodes: number, layout: LayoutType ): { x: number; y: number } { switch (layout) { case "circular": { const radius = Math.max(300, totalNodes * 20); const angle = (index / totalNodes) * 2 * Math.PI; return { x: 400 + radius * Math.cos(angle), y: 400 + radius * Math.sin(angle), }; } case "hierarchical": { const cols = Math.ceil(Math.sqrt(totalNodes)); return { x: (index % cols) * 250, y: Math.floor(index / cols) * 200, }; } case "force": default: // Random initial positions for force layout return { x: Math.random() * 800, y: Math.random() * 600, }; } } /** * Apply ELK hierarchical layout */ async function applyElkLayout(nodes: Node[], edges: Edge[]): Promise { const graph = { id: "root", layoutOptions: { "elk.algorithm": "layered", "elk.direction": "DOWN", "elk.spacing.nodeNode": "80", "elk.layered.spacing.nodeNodeBetweenLayers": "100", }, children: nodes.map((node) => ({ id: node.id, width: typeof node.style?.width === "number" ? node.style.width : 100, height: typeof node.style?.height === "number" ? node.style.height : 100, })), edges: edges.map((edge) => ({ id: edge.id, sources: [edge.source], targets: [edge.target], })), }; const layout = await elk.layout(graph); return nodes.map((node) => { const elkNode = layout.children?.find((n) => n.id === node.id); if (elkNode?.x !== undefined && elkNode.y !== undefined) { return { ...node, position: { x: elkNode.x, y: elkNode.y }, }; } return node; }); } export function KnowledgeGraphViewer({ initialFilters, }: KnowledgeGraphViewerProps): React.JSX.Element { const router = useRouter(); const [graphData, setGraphData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [layout, setLayout] = useState("force"); const [showOrphans, setShowOrphans] = useState(true); const [statusFilter, setStatusFilter] = useState(initialFilters?.status ?? ""); const [tagFilter, setTagFilter] = useState(""); // Load graph data const loadGraph = useCallback(async (): Promise => { try { setIsLoading(true); setError(null); const data = await fetchKnowledgeGraph(initialFilters); setGraphData(data); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load graph"); } finally { setIsLoading(false); } }, [initialFilters]); useEffect(() => { void loadGraph(); }, [loadGraph]); // Filter nodes based on criteria const filteredNodes = useMemo(() => { if (!graphData) return []; let filtered = graphData.nodes; // Filter by orphan status if (!showOrphans) { filtered = filtered.filter((node) => !node.isOrphan); } // Filter by status if (statusFilter) { filtered = filtered.filter((node) => node.status === statusFilter); } // Filter by tag if (tagFilter) { const lowerFilter = tagFilter.toLowerCase(); filtered = filtered.filter((node) => node.tags.some((tag) => tag.name.toLowerCase().includes(lowerFilter)) ); } return filtered; }, [graphData, showOrphans, statusFilter, tagFilter]); // Filter edges to only include those between visible nodes const filteredEdges = useMemo(() => { if (!graphData) return []; const nodeIds = new Set(filteredNodes.map((n) => n.id)); return graphData.edges.filter( (edge) => nodeIds.has(edge.sourceId) && nodeIds.has(edge.targetId) ); }, [graphData, filteredNodes]); // Convert to ReactFlow format const initialNodes = useMemo( () => convertToReactFlowNodes(filteredNodes, filteredEdges, layout), [filteredNodes, filteredEdges, layout] ); const initialEdges = useMemo(() => convertToReactFlowEdges(filteredEdges), [filteredEdges]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); // Update nodes when layout or data changes useEffect(() => { const updateLayout = async (): Promise => { let newNodes = convertToReactFlowNodes(filteredNodes, filteredEdges, layout); if (layout === "hierarchical") { newNodes = await applyElkLayout(newNodes, initialEdges); } setNodes(newNodes); }; void updateLayout(); }, [filteredNodes, filteredEdges, layout, initialEdges, setNodes]); // Update edges when data changes useEffect(() => { setEdges(initialEdges); }, [initialEdges, setEdges]); // Handle node click - navigate to entry const handleNodeClick = useCallback( (_event: React.MouseEvent, node: Node): void => { const slug = node.data.slug as string; if (slug) { router.push(`/knowledge/${slug}`); } }, [router] ); // Handle layout change const handleLayoutChange = useCallback((newLayout: LayoutType): void => { setLayout(newLayout); }, []); if (isLoading) { return (
); } if (error || !graphData) { return (
Error Loading Graph
{error}
); } return (
{/* Header */}

Knowledge Graph

{filteredNodes.length} entries • {filteredEdges.length} connections {graphData.stats.orphanCount > 0 && ( • {graphData.stats.orphanCount} orphaned )}
{/* Layout Controls */}
{(["force", "hierarchical", "circular"] as const).map((layoutType) => ( ))}
{/* Filters */}
{/* Status Filter */}
{/* Tag Filter */}
{ setTagFilter(e.target.value); }} placeholder="Filter by tags..." className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" />
{/* Orphan Toggle */}
{/* Graph Visualization */}
{ const bgColor = node.style?.backgroundColor; return typeof bgColor === "string" ? bgColor : "#3B82F6"; }} maskColor="rgba(0, 0, 0, 0.1)" />
Published (Active)
Draft (Upcoming)
Archived (Dormant)
Orphaned
); }