import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useAuth } from '../context/AuthContext'; import type { GraphNode, GraphEdge, DocumentInfo } from '../types/api'; import GraphCanvas, { DEFAULT_OPTIONS } from '../components/GraphCanvas'; import type { GraphOptions, GraphCanvasHandle } from '../components/GraphCanvas'; import { RefreshCw, Play, Database, Info, Maximize2, Minimize2, Download, SlidersHorizontal, X, Layers, Tag, Search, Image, GitBranch, HelpCircle } from 'lucide-react'; const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api'; const TYPE_COLORS = [ '#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261', '#6a4c93','#1982c4','#8ac926','#ff595e','#6a994e', '#bc4749','#a8dadc' ]; const SimulationRunView: React.FC = () => { const { token, logout } = useAuth(); const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [] }); const [loading, setLoading] = useState(false); const [limit, setLimit] = useState(100); const [documents, setDocuments] = useState([]); const [selectedDocId, setSelectedDocId] = useState(''); const [nodeCount, setNodeCount] = useState(0); const [edgeCount, setEdgeCount] = useState(0); // UI state const [isFullscreen, setIsFullscreen] = useState(false); const [showPanel, setShowPanel] = useState(false); const [showHelp, setShowHelp] = useState(false); const [showSearch, setShowSearch] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [highlightNodeIds, setHighlightNodeIds] = useState>(new Set()); // Graph options const [options, setOptions] = useState({ ...DEFAULT_OPTIONS }); // Canvas handle const canvasRef = useRef(null); const containerRef = useRef(null); // Derived legend const typeLegend = React.useMemo(() => { const types = [...new Set(graphData.nodes.map(n => n.type || 'Unknown'))]; return types.map((t, i) => ({ type: t, color: TYPE_COLORS[i % TYPE_COLORS.length] })); }, [graphData.nodes]); // Graph stats const degreeStats = React.useMemo(() => { if (!graphData.nodes.length) return null; const degMap = new Map(); graphData.nodes.forEach(n => degMap.set(n.id, 0)); graphData.edges.forEach(e => { degMap.set(e.source, (degMap.get(e.source) || 0) + 1); degMap.set(e.target, (degMap.get(e.target) || 0) + 1); }); const degrees = [...degMap.values()]; const avg = degrees.reduce((a, b) => a + b, 0) / degrees.length; const max = Math.max(...degrees); const hubs = graphData.nodes.filter(n => (degMap.get(n.id) || 0) >= avg * 2); return { avg: avg.toFixed(1), max, hubs: hubs.slice(0, 5) }; }, [graphData]); // ── Data fetching ────────────────────────────────────────────────────── const fetchDocuments = useCallback(async () => { try { const res = await fetch(`${API_BASE}/documents`, { headers: { Authorization: `Bearer ${token}` } }); if (res.ok) setDocuments((await res.json()).documents); } catch {} }, [token]); const fetchGraph = useCallback(async (docId: string, nodeLimit: number) => { setLoading(true); try { const url = new URL(`${API_BASE}/graph/visualization`); url.searchParams.append('limit', nodeLimit.toString()); if (docId) url.searchParams.append('document_id', docId); const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } }); if (res.status === 401) { logout(); return; } if (res.ok) { const data = await res.json(); setGraphData(data); setNodeCount(data.nodes?.length ?? 0); setEdgeCount(data.edges?.length ?? 0); setHighlightNodeIds(new Set()); setSearchQuery(''); } } catch (err) { console.error('Graph fetch error:', err); } finally { setLoading(false); } }, [token, logout]); const handleNodeUpdate = useCallback(async (nodeId: string, newName: string) => { try { const res = await fetch(`${API_BASE}/entities/${nodeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: newName }) }); if (res.status === 401) { logout(); return; } if (res.ok) { setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => n.id === nodeId ? { ...n, label: newName } : n) })); } } catch {} }, [token, logout]); useEffect(() => { fetchDocuments(); }, [fetchDocuments]); useEffect(() => { fetchGraph(selectedDocId, limit); }, [fetchGraph, selectedDocId, limit]); // Search handler const handleSearch = useCallback((q: string) => { setSearchQuery(q); if (!q.trim()) { setHighlightNodeIds(new Set()); return; } const lower = q.toLowerCase(); const matched = new Set( graphData.nodes .filter(n => n.label?.toLowerCase().includes(lower) || n.type?.toLowerCase().includes(lower) ) .map(n => n.id) ); setHighlightNodeIds(matched); // Zoom to first match if (matched.size > 0) { const firstId = [...matched][0]; canvasRef.current?.highlightNode(firstId); } }, [graphData.nodes]); // ── Fullscreen ───────────────────────────────────────────────────────── const toggleFullscreen = useCallback(() => { if (!isFullscreen) { containerRef.current?.requestFullscreen?.().catch(() => setIsFullscreen(true)); } else { document.exitFullscreen?.().catch(() => setIsFullscreen(false)); } setIsFullscreen(f => !f); }, [isFullscreen]); useEffect(() => { const handler = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', handler); return () => document.removeEventListener('fullscreenchange', handler); }, []); // ── Option helpers ───────────────────────────────────────────────────── const setOpt = (key: K, val: GraphOptions[K]) => setOptions(prev => ({ ...prev, [key]: val })); const selectedDocName = documents.find(d => d.id === selectedDocId)?.filename || ''; return (
{/* ── Top bar ──────────────────────────────────────────────────────── */}

GRAPH VISUALIZATION

{nodeCount} nodes {edgeCount} edges {degreeStats && avg° {degreeStats.avg}} {selectedDocId && ( {selectedDocName.length > 22 ? selectedDocName.substring(0,20)+'…' : selectedDocName} )}
{/* Doc filter */}
{/* Limit */}
{/* Action buttons */}
{/* Edge labels quick toggle */} {/* Search */} {/* Export PNG */} {/* Export SVG */} {/* Options panel */} {/* Fullscreen */} {/* Help */}
{/* ── Node Search bar ──────────────────────────────────────────────── */} {showSearch && (
handleSearch(e.target.value)} /> {highlightNodeIds.size > 0 && ( {highlightNodeIds.size} match{highlightNodeIds.size !== 1 ? 'es' : ''} )}
)} {/* ── Help modal ───────────────────────────────────────────────────── */} {showHelp && (
CONTROLS REFERENCE
MOUSE / TOUCH
{[ ['Single click', 'Zoom to node'], ['Double click', 'Edit node name'], ['Drag node', 'Pin node position'], ['Hover', 'Highlight neighbors'], ['Scroll', 'Zoom in / out'], ['Drag canvas', 'Pan view'], ['Click canvas bg', 'Reset highlight'], ].map(([k, v]) => ( ))}
{k}{v}
TOOLBAR BUTTONS
{[ ['REFRESH', 'Reload graph from Neo4j'], ['FIT', 'Reset zoom/pan to center'], ['LABELS', 'Toggle edge relation text'], ['SEARCH', 'Find & highlight nodes'], ['PNG/SVG', 'Export graph image'], ['OPTIONS', 'Physics & display settings'], ['FULL', 'Toggle fullscreen mode'], ].map(([k, v]) => ( ))}
{k}{v}
TIPS
  • Set NODES to 50 for better performance on large graphs
  • Enable Curved edges to reduce visual overlap
  • Node size by degree makes hubs visually prominent
  • Use SEARCH to find entities by name or type
  • Increase Charge Strength to spread out dense clusters
)} {/* ── Advanced options panel ────────────────────────────────────────── */} {showPanel && (
ADVANCED GRAPH OPTIONS
{/* Display toggles */}
DISPLAY
{([ ['colorByType', 'Color nodes by entity type'], ['showLabels', 'Show node name labels'], ['showEdgeLabels', 'Show edge relation labels'], ['showCurvedEdges', 'Curved edges (arc routing)'], ['nodeSizeByDegree', 'Node size by connection count'], ] as [keyof GraphOptions, string][]).map(([key, label]) => ( ))}
{/* Physics sliders */}
PHYSICS
{([ ['nodeRadius', 'Node Radius', 8, 40, 1], ['linkDistance', 'Link Distance', 40, 500, 10], ['chargeStrength', 'Repulsion', -1200, -30, 10], ['centerGravity', 'Center Gravity', 0, 0.5, 0.01], ] as [keyof GraphOptions, string, number, number, number][]).map(([key, label, min, max, step]) => (
setOpt(key, Number(e.target.value))} />
))}
)} {/* ── Canvas + Legend + Stats ───────────────────────────────────────── */}
{loading && graphData.nodes.length === 0 ? (
INITIALIZING PHYSICS ENGINE...
) : graphData.nodes.length === 0 ? (
{selectedDocId ? 'No entities found for this document.\nTry a different document or re-ingest.' : 'No entity data in graph.\nIngest documents via the PROCESS tab first.'}
) : ( )} {loading && graphData.nodes.length > 0 && (
REFRESHING
)}
{/* ── Sidebar: Legend + Stats ─────────────────────────────────────── */}
{/* Type Legend */} {options.colorByType && typeLegend.length > 0 && (
ENTITY TYPES
{typeLegend.map(({ type, color }) => (
{type}
))}
)} {/* Graph stats */} {degreeStats && (
NETWORK STATS
Avg Degree{degreeStats.avg}
Max Degree{degreeStats.max}
{degreeStats.hubs.length > 0 && ( <>
HUB NODES
{degreeStats.hubs.map((n: any) => (
canvasRef.current?.highlightNode(n.id)} > {n.label?.length > 14 ? n.label.substring(0,12)+'…' : n.label}
))} )}
)}
{/* ── Hint bar ──────────────────────────────────────────────────────── */}
Click node to zoom · Double-click to rename · Hover to highlight neighbors · Drag to pin · Scroll to zoom
); }; export default SimulationRunView;