Datavision / frontend /src /components /GraphVisualization.tsx
DataVision CI/CD Bot
release: clean production build for HuggingFace Space
09801ca
Raw
History Blame Contribute Delete
30.8 kB
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<GraphVisualizationProps> = ({ userId, maxNodes = 100 }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const fullscreenCanvasRef = useRef<HTMLCanvasElement>(null);
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<Map<string, { x: number; y: number; vx: number; vy: number }>>(new Map());
const fullscreenPositions = useRef<Map<string, { x: number; y: number; vx: number; vy: number }>>(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<string, { x: number; y: number; vx?: number; vy?: number }>,
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<string, { x: number; y: number }>();
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 (
<div className="flex items-center justify-center h-64 bg-white rounded-xl border border-gray-200">
<div className="text-center">
<Network className="w-12 h-12 text-primary-500 animate-pulse mx-auto mb-3" />
<p className="text-gray-500">Loading knowledge graph...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-64 bg-white rounded-xl border border-gray-200">
<div className="text-center">
<Network className="w-12 h-12 text-red-400 mx-auto mb-3" />
<p className="text-red-500">Error: {error}</p>
</div>
</div>
);
}
return (
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-sm"
>
{/* Header */}
<div className="p-4 border-b border-gray-200 flex items-center justify-between bg-gray-50">
<div className="flex items-center space-x-3">
<Network className="w-5 h-5 text-primary-500" />
<div>
<h3 className="text-gray-800 font-semibold">Knowledge Graph Network</h3>
<p className="text-xs text-gray-500">
{graphData?.stats.displayed_nodes} nodes, {graphData?.stats.displayed_edges} edges
</p>
</div>
</div>
{/* Controls */}
<div className="flex items-center space-x-2">
<button
onClick={handleZoomIn}
className="p-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-600 hover:text-gray-800 transition-all"
title="Zoom In"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={handleZoomOut}
className="p-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-600 hover:text-gray-800 transition-all"
title="Zoom Out"
>
<ZoomOut className="w-4 h-4" />
</button>
<button
onClick={handleFullscreen}
className="p-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-all"
title="Full Screen View"
>
<Maximize2 className="w-4 h-4" />
</button>
<button
onClick={handleDownload}
className="p-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-600 hover:text-gray-800 transition-all"
title="Download High-Quality PNG"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
{/* Canvas */}
<div className="relative">
<canvas
ref={canvasRef}
width={800}
height={500}
className="w-full bg-white cursor-move"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
</div>
{/* Legend */}
<div className="p-4 border-t border-gray-200 bg-gray-50">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span className="text-gray-600">Customers ({graphData?.stats.customers || 0})</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span className="text-gray-600">Products ({graphData?.stats.products || 0})</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
<span className="text-gray-600">Invoices ({graphData?.stats.invoices || 0})</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-gray-500"></div>
<span className="text-gray-600">Other</span>
</div>
</div>
<span className="text-gray-500">Scroll to zoom • Drag to pan</span>
</div>
</div>
</motion.div>
{/* Fullscreen Modal */}
<AnimatePresence>
{isFullscreen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && handleCloseFullscreen()}
>
<div className="w-full h-full flex flex-col p-6">
{/* Fullscreen Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4">
<Network className="w-8 h-8 text-primary-400" />
<div>
<h2 className="text-2xl font-bold text-white">Knowledge Graph</h2>
<p className="text-gray-400">
{graphData?.stats.total_nodes} nodes • {graphData?.stats.total_edges} relationships
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleZoomIn}
className="p-3 bg-white/10 hover:bg-white/20 rounded-xl text-white transition-all"
title="Zoom In"
>
<ZoomIn className="w-5 h-5" />
</button>
<button
onClick={handleZoomOut}
className="p-3 bg-white/10 hover:bg-white/20 rounded-xl text-white transition-all"
title="Zoom Out"
>
<ZoomOut className="w-5 h-5" />
</button>
<button
onClick={handleReset}
className="p-3 bg-white/10 hover:bg-white/20 rounded-xl text-white transition-all"
title="Reset View"
>
<Minimize2 className="w-5 h-5" />
</button>
<button
onClick={handleDownload}
className="p-3 bg-primary-500 hover:bg-primary-600 rounded-xl text-white transition-all flex items-center space-x-2"
title="Download High-Quality PNG"
>
<Download className="w-5 h-5" />
<span>Download</span>
</button>
<button
onClick={handleCloseFullscreen}
className="p-3 bg-red-500/80 hover:bg-red-500 rounded-xl text-white transition-all"
title="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Fullscreen Canvas */}
<div className="flex-1 bg-white rounded-2xl overflow-hidden shadow-2xl">
<canvas
ref={fullscreenCanvasRef}
className="w-full h-full cursor-move"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
</div>
{/* Fullscreen Legend */}
<div className="mt-4 flex items-center justify-center space-x-8 text-sm">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
<span className="text-white">Customers ({graphData?.stats.customers || 0})</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-4 h-4 rounded-full bg-green-500"></div>
<span className="text-white">Products ({graphData?.stats.products || 0})</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-4 h-4 rounded-full bg-orange-500"></div>
<span className="text-white">Invoices ({graphData?.stats.invoices || 0})</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-4 h-4 rounded-full bg-gray-500"></div>
<span className="text-white">Other</span>
</div>
<span className="text-gray-400 ml-4">Scroll to zoom • Drag to pan • ESC to close</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default GraphVisualization;