import React, { useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Network, ZoomIn, ZoomOut, Maximize2, Download, X, Minimize2 } from 'lucide-react'; import { getAuthHeadersSync } from '@/utils/userId'; interface GraphNode { id: string; label: string; type: string; metadata?: any; } interface GraphEdge { source: string; target: string; relation: string; } interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; stats: { total_nodes: number; total_edges: number; displayed_nodes: number; displayed_edges: number; customers?: number; products?: number; invoices?: number; }; } interface GraphVisualizationProps { userId: string; maxNodes?: number; } const GraphVisualization: React.FC = ({ userId, maxNodes = 100 }) => { const canvasRef = useRef(null); const fullscreenCanvasRef = useRef(null); const [graphData, setGraphData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [isFullscreen, setIsFullscreen] = useState(false); const [fullscreenZoom, setFullscreenZoom] = useState(1); const [fullscreenOffset, setFullscreenOffset] = useState({ x: 0, y: 0 }); // Node positions for force simulation const nodePositions = useRef>(new Map()); const fullscreenPositions = useRef>(new Map()); useEffect(() => { fetchGraphData(); }, [userId, maxNodes]); useEffect(() => { // Set canvas size on mount and resize const updateCanvasSize = () => { const canvas = canvasRef.current; if (canvas) { const container = canvas.parentElement; if (container) { const rect = container.getBoundingClientRect(); canvas.width = rect.width || container.clientWidth; canvas.height = rect.height || Math.min(container.clientHeight, 200); // Redraw if we have data if (graphData && nodePositions.current.size > 0) { drawGraph(canvas, nodePositions.current, zoom, offset); } } } }; updateCanvasSize(); // Use ResizeObserver for dynamic resize const canvas = canvasRef.current; if (canvas?.parentElement) { const observer = new ResizeObserver(updateCanvasSize); observer.observe(canvas.parentElement); return () => observer.disconnect(); } }, [graphData, zoom, offset]); const fetchGraphData = async () => { try { setIsLoading(true); const response = await fetch(`/api/v1/graph/${userId}/visualization?max_nodes=${maxNodes}`, { headers: getAuthHeadersSync() }); if (!response.ok) throw new Error('Failed to fetch graph data'); const data: GraphData = await response.json(); setGraphData(data); initializeNodePositions(data); setError(null); } catch (err: any) { setError(err.message); console.error('Graph fetch error:', err); } finally { setIsLoading(false); } }; const initializeNodePositions = (data: GraphData) => { const canvas = canvasRef.current; if (!canvas) return; const centerX = canvas.width / 2; const centerY = canvas.height / 2; const radius = Math.min(canvas.width, canvas.height) / 3; // Initialize positions in a circle data.nodes.forEach((node, i) => { const angle = (i / data.nodes.length) * 2 * Math.PI; nodePositions.current.set(node.id, { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle), vx: 0, vy: 0, }); }); startSimulation(data); }; const startSimulation = (data: GraphData) => { let animationId: number; let iterations = 0; const maxIterations = 300; const simulate = () => { if (iterations++ > maxIterations) { cancelAnimationFrame(animationId); return; } // Force-directed layout simulation const nodes = data.nodes; const edges = data.edges; const k = 50; // Spring constant const repulsion = 5000; // Repulsion force const damping = 0.8; // Reset forces nodePositions.current.forEach((pos) => { pos.vx *= damping; pos.vy *= damping; }); // Repulsion between all nodes for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const pos1 = nodePositions.current.get(nodes[i].id)!; const pos2 = nodePositions.current.get(nodes[j].id)!; const dx = pos2.x - pos1.x; const dy = pos2.y - pos1.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const force = repulsion / (distance * distance); const fx = (dx / distance) * force; const fy = (dy / distance) * force; pos1.vx -= fx; pos1.vy -= fy; pos2.vx += fx; pos2.vy += fy; } } // Attraction along edges edges.forEach((edge) => { const pos1 = nodePositions.current.get(edge.source); const pos2 = nodePositions.current.get(edge.target); if (pos1 && pos2) { const dx = pos2.x - pos1.x; const dy = pos2.y - pos1.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const force = (distance - k) * 0.1; const fx = (dx / distance) * force; const fy = (dy / distance) * force; pos1.vx += fx; pos1.vy += fy; pos2.vx -= fx; pos2.vy -= fy; } }); // Update positions nodePositions.current.forEach((pos) => { pos.x += pos.vx; pos.y += pos.vy; }); const canvas = canvasRef.current; if (canvas) { drawGraph(canvas, nodePositions.current, zoom, offset); } animationId = requestAnimationFrame(simulate); }; simulate(); }; const drawGraph = ( canvas: HTMLCanvasElement, positions: Map, currentZoom: number, currentOffset: { x: number; y: number } ) => { if (!canvas || !graphData) return; const ctx = canvas.getContext('2d'); if (!ctx) return; // Clear canvas ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); // Apply zoom and pan ctx.translate(currentOffset.x, currentOffset.y); ctx.scale(currentZoom, currentZoom); // Draw edges ctx.strokeStyle = 'rgba(100, 116, 139, 0.3)'; ctx.lineWidth = 1; graphData.edges.forEach((edge) => { const source = positions.get(edge.source); const target = positions.get(edge.target); if (source && target) { ctx.beginPath(); ctx.moveTo(source.x, source.y); ctx.lineTo(target.x, target.y); ctx.stroke(); } }); // Draw nodes graphData.nodes.forEach((node) => { const pos = positions.get(node.id); if (!pos) return; // Color by type let color = '#22c55e'; // Default teal if (node.type === 'customer') color = '#3b82f6'; // Blue else if (node.type === 'product') color = '#10b981'; // Green else if (node.type === 'invoice') color = '#f59e0b'; // Orange else if (node.type === 'date') color = '#6b7280'; // Gray // Draw node circle with border ctx.fillStyle = color; ctx.beginPath(); ctx.arc(pos.x, pos.y, 8, 0, 2 * Math.PI); ctx.fill(); // White border for visibility ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.stroke(); // Draw label with background for visibility - HIGH CONTRAST for light/dark modes const label = node.label.length > 25 ? node.label.substring(0, 22) + '...' : node.label; ctx.font = 'bold 12px Inter, sans-serif'; const textWidth = ctx.measureText(label).width; // Label background (clean white with shadow effect) ctx.fillStyle = '#ffffff'; ctx.shadowColor = 'rgba(0, 0, 0, 0.2)'; ctx.shadowBlur = 4; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 2; const padding = 6; const rectX = pos.x - textWidth / 2 - padding; const rectY = pos.y - 30; // Move up a bit more const rectW = textWidth + (padding * 2); const rectH = 18; ctx.beginPath(); // Round rect const r = 4; ctx.moveTo(rectX + r, rectY); ctx.lineTo(rectX + rectW - r, rectY); ctx.quadraticCurveTo(rectX + rectW, rectY, rectX + rectW, rectY + r); ctx.lineTo(rectX + rectW, rectY + rectH - r); ctx.quadraticCurveTo(rectX + rectW, rectY + rectH, rectX + rectW - r, rectY + rectH); ctx.lineTo(rectX + r, rectY + rectH); ctx.quadraticCurveTo(rectX, rectY + rectH, rectX, rectY + rectH - r); ctx.lineTo(rectX, rectY + r); ctx.quadraticCurveTo(rectX, rectY, rectX + r, rectY); ctx.closePath(); ctx.fill(); // Reset shadow for text ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; // Label border (match node color) ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke(); // Label text (pure black for maximum contrast) ctx.fillStyle = '#000000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, pos.x, rectY + rectH / 2); }); ctx.restore(); }; useEffect(() => { if (graphData && nodePositions.current.size > 0) { const canvas = canvasRef.current; if (canvas && canvas.width > 0) { drawGraph(canvas, nodePositions.current, zoom, offset); } } }, [graphData, zoom, offset]); // Draw fullscreen canvas when open useEffect(() => { if (isFullscreen && graphData && fullscreenPositions.current.size > 0) { const canvas = fullscreenCanvasRef.current; if (canvas && canvas.width > 0) { drawGraph(canvas, fullscreenPositions.current, fullscreenZoom, fullscreenOffset); } } }, [isFullscreen, graphData, fullscreenZoom, fullscreenOffset]); const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; if (isFullscreen) { setFullscreenZoom((prev) => Math.max(0.1, Math.min(prev * delta, 5))); } else { setZoom((prev) => Math.max(0.1, Math.min(prev * delta, 3))); } }; const handleMouseDown = (e: React.MouseEvent) => { setIsDragging(true); if (isFullscreen) { setDragStart({ x: e.clientX - fullscreenOffset.x, y: e.clientY - fullscreenOffset.y }); } else { setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y }); } }; const handleMouseMove = (e: React.MouseEvent) => { if (isDragging) { if (isFullscreen) { setFullscreenOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y, }); } else { setOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y, }); } } }; const handleMouseUp = () => { setIsDragging(false); }; const handleZoomIn = () => { if (isFullscreen) { setFullscreenZoom((prev) => Math.min(prev * 1.2, 5)); } else { setZoom((prev) => Math.min(prev * 1.2, 3)); } }; const handleZoomOut = () => { if (isFullscreen) { setFullscreenZoom((prev) => Math.max(prev / 1.2, 0.1)); } else { setZoom((prev) => Math.max(prev / 1.2, 0.1)); } }; const handleReset = () => { if (isFullscreen) { setFullscreenZoom(1); setFullscreenOffset({ x: 0, y: 0 }); } else { setZoom(1); setOffset({ x: 0, y: 0 }); } }; // Open fullscreen modal const handleFullscreen = () => { setIsFullscreen(true); setFullscreenZoom(1); setFullscreenOffset({ x: 0, y: 0 }); }; // Initialize fullscreen canvas when modal opens useEffect(() => { if (isFullscreen && graphData) { // Small delay to ensure canvas is mounted const timer = setTimeout(() => { initializeFullscreenPositions(graphData); }, 150); return () => clearTimeout(timer); } }, [isFullscreen, graphData]); const initializeFullscreenPositions = (data: GraphData) => { const canvas = fullscreenCanvasRef.current; if (!canvas) { return; } // Set canvas to full window size const width = window.innerWidth - 48; const height = window.innerHeight - 180; canvas.width = width; canvas.height = height; const centerX = width / 2; const centerY = height / 2; const radius = Math.min(width, height) / 3; // Clear and initialize positions fullscreenPositions.current.clear(); // Initialize positions in a circle with more spread data.nodes.forEach((node, i) => { const angle = (i / data.nodes.length) * 2 * Math.PI; fullscreenPositions.current.set(node.id, { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle), vx: 0, vy: 0, }); }); // Run simulation runFullscreenSimulation(data); }; const runFullscreenSimulation = (data: GraphData) => { let iterations = 0; const maxIterations = 200; const simulate = () => { const canvas = fullscreenCanvasRef.current; if (iterations++ > maxIterations || !canvas) { // Final draw if (canvas) { drawGraph(canvas, fullscreenPositions.current, fullscreenZoom, fullscreenOffset); } return; } const nodes = data.nodes; const edges = data.edges; const k = 100; const repulsion = 20000; const damping = 0.85; fullscreenPositions.current.forEach((pos) => { pos.vx *= damping; pos.vy *= damping; }); for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const pos1 = fullscreenPositions.current.get(nodes[i].id)!; const pos2 = fullscreenPositions.current.get(nodes[j].id)!; const dx = pos2.x - pos1.x; const dy = pos2.y - pos1.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const force = repulsion / (distance * distance); const fx = (dx / distance) * force; const fy = (dy / distance) * force; pos1.vx -= fx; pos1.vy -= fy; pos2.vx += fx; pos2.vy += fy; } } edges.forEach((edge) => { const pos1 = fullscreenPositions.current.get(edge.source); const pos2 = fullscreenPositions.current.get(edge.target); if (pos1 && pos2) { const dx = pos2.x - pos1.x; const dy = pos2.y - pos1.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const force = (distance - k) * 0.1; const fx = (dx / distance) * force; const fy = (dy / distance) * force; pos1.vx += fx; pos1.vy += fy; pos2.vx -= fx; pos2.vy -= fy; } }); fullscreenPositions.current.forEach((pos) => { pos.x += pos.vx; pos.y += pos.vy; }); drawGraph(canvas, fullscreenPositions.current, fullscreenZoom, fullscreenOffset); requestAnimationFrame(simulate); }; simulate(); }; const handleCloseFullscreen = () => { setIsFullscreen(false); fullscreenPositions.current.clear(); }; // ESC key to close fullscreen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isFullscreen) { handleCloseFullscreen(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isFullscreen]); // Download high-quality image const handleDownload = () => { if (!graphData) return; // Create a high-res canvas for download (1920x1080) const downloadCanvas = document.createElement('canvas'); downloadCanvas.width = 1920; downloadCanvas.height = 1080; const ctx = downloadCanvas.getContext('2d'); if (!ctx) return; // White background ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, downloadCanvas.width, downloadCanvas.height); // Create positions for download canvas const downloadPositions = new Map(); const centerX = downloadCanvas.width / 2; const centerY = downloadCanvas.height / 2; const radius = Math.min(downloadCanvas.width, downloadCanvas.height) / 2.5; graphData.nodes.forEach((node, i) => { const angle = (i / graphData.nodes.length) * 2 * Math.PI; downloadPositions.set(node.id, { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle), }); }); // Run quick simulation for (let iter = 0; iter < 100; iter++) { const k = 100; const repulsion = 20000; for (let i = 0; i < graphData.nodes.length; i++) { for (let j = i + 1; j < graphData.nodes.length; j++) { const pos1 = downloadPositions.get(graphData.nodes[i].id)!; const pos2 = downloadPositions.get(graphData.nodes[j].id)!; const dx = pos2.x - pos1.x; const dy = pos2.y - pos1.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const force = repulsion / (distance * distance); pos1.x -= (dx / distance) * force * 0.01; pos1.y -= (dy / distance) * force * 0.01; pos2.x += (dx / distance) * force * 0.01; pos2.y += (dy / distance) * force * 0.01; } } graphData.edges.forEach((edge) => { const pos1 = downloadPositions.get(edge.source); const pos2 = downloadPositions.get(edge.target); if (pos1 && pos2) { const dx = pos2.x - pos1.x; const dy = pos2.y - pos1.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const force = (distance - k) * 0.005; pos1.x += (dx / distance) * force; pos1.y += (dy / distance) * force; pos2.x -= (dx / distance) * force; pos2.y -= (dy / distance) * force; } }); } // Draw edges ctx.strokeStyle = 'rgba(100, 116, 139, 0.4)'; ctx.lineWidth = 2; graphData.edges.forEach((edge) => { const source = downloadPositions.get(edge.source); const target = downloadPositions.get(edge.target); if (source && target) { ctx.beginPath(); ctx.moveTo(source.x, source.y); ctx.lineTo(target.x, target.y); ctx.stroke(); } }); // Draw nodes graphData.nodes.forEach((node) => { const pos = downloadPositions.get(node.id); if (!pos) return; let color = '#8b5cf6'; if (node.type === 'customer') color = '#3b82f6'; else if (node.type === 'product') color = '#10b981'; else if (node.type === 'invoice') color = '#f59e0b'; else if (node.type === 'date') color = '#6b7280'; // Node circle ctx.fillStyle = color; ctx.beginPath(); ctx.arc(pos.x, pos.y, 16, 0, 2 * Math.PI); ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 3; ctx.stroke(); // Label const label = node.label.substring(0, 15); ctx.font = 'bold 14px Inter, sans-serif'; const textWidth = ctx.measureText(label).width; ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'; ctx.fillRect(pos.x - textWidth / 2 - 6, pos.y - 40, textWidth + 12, 22); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.strokeRect(pos.x - textWidth / 2 - 6, pos.y - 40, textWidth + 12, 22); ctx.fillStyle = '#1f2937'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, pos.x, pos.y - 29); }); // Add title ctx.fillStyle = '#1f2937'; ctx.font = 'bold 24px Inter, sans-serif'; ctx.textAlign = 'left'; ctx.fillText('Knowledge Graph', 30, 40); ctx.font = '16px Inter, sans-serif'; ctx.fillStyle = '#6b7280'; ctx.fillText(`${graphData.stats.total_nodes} nodes • ${graphData.stats.total_edges} relationships`, 30, 70); // Add legend const legendY = downloadCanvas.height - 40; const legendItems = [ { color: '#3b82f6', label: `Customers (${graphData.stats.customers || 0})` }, { color: '#10b981', label: `Products (${graphData.stats.products || 0})` }, { color: '#f59e0b', label: `Invoices (${graphData.stats.invoices || 0})` }, { color: '#6b7280', label: 'Other' }, ]; let legendX = 30; legendItems.forEach((item) => { ctx.fillStyle = item.color; ctx.beginPath(); ctx.arc(legendX, legendY, 8, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = '#374151'; ctx.font = '14px Inter, sans-serif'; ctx.textAlign = 'left'; ctx.fillText(item.label, legendX + 15, legendY + 5); legendX += ctx.measureText(item.label).width + 50; }); // Download const link = document.createElement('a'); link.download = `knowledge-graph-${userId}-${new Date().toISOString().split('T')[0]}.png`; link.href = downloadCanvas.toDataURL('image/png', 1.0); link.click(); }; if (isLoading) { return (

Loading knowledge graph...

); } if (error) { return (

Error: {error}

); } return ( <> {/* Header */}

Knowledge Graph Network

{graphData?.stats.displayed_nodes} nodes, {graphData?.stats.displayed_edges} edges

{/* Controls */}
{/* Canvas */}
{/* Legend */}
Customers ({graphData?.stats.customers || 0})
Products ({graphData?.stats.products || 0})
Invoices ({graphData?.stats.invoices || 0})
Other
Scroll to zoom • Drag to pan
{/* Fullscreen Modal */} {isFullscreen && ( e.target === e.currentTarget && handleCloseFullscreen()} >
{/* Fullscreen Header */}

Knowledge Graph

{graphData?.stats.total_nodes} nodes • {graphData?.stats.total_edges} relationships

{/* Fullscreen Canvas */}
{/* Fullscreen Legend */}
Customers ({graphData?.stats.customers || 0})
Products ({graphData?.stats.products || 0})
Invoices ({graphData?.stats.invoices || 0})
Other
Scroll to zoom • Drag to pan • ESC to close
)}
); }; export default GraphVisualization;