Spaces:
Running
Running
| import { useEffect, useRef, useState, useMemo } from 'react'; | |
| import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d'; | |
| import { forceRadial } from 'd3-force'; | |
| import type { GraphData } from '@/types/graph'; | |
| import { getGraphData } from '@/services/api'; | |
| import { Loader2, AlertCircle } from 'lucide-react'; | |
| import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; | |
| interface GraphViewProps { | |
| onSelectNote: (path: string) => void; | |
| refreshTrigger?: number; | |
| } | |
| export function GraphView({ onSelectNote, refreshTrigger }: GraphViewProps) { | |
| console.log('[GraphView] Component rendered, refreshTrigger:', refreshTrigger); | |
| const [data, setData] = useState<GraphData>({ nodes: [], links: [] }); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const graphRef = useRef<ForceGraphMethods | undefined>(undefined); | |
| // Theme detection would go here, simplified for MVP | |
| const isDark = document.documentElement.classList.contains('dark'); | |
| // Load saved view state | |
| useEffect(() => { | |
| if (!isLoading && graphRef.current) { | |
| const savedView = localStorage.getItem('graph-view-state'); | |
| if (savedView) { | |
| const { x, y, k } = JSON.parse(savedView); | |
| graphRef.current.centerAt(x, y, 0); | |
| graphRef.current.zoom(k, 0); | |
| } | |
| } | |
| }, [isLoading]); | |
| // Save view state on unmount | |
| useEffect(() => { | |
| return () => { | |
| if (graphRef.current) { | |
| // Note: react-force-graph types might be incomplete for getting state directly | |
| // This is a best-effort implementation. | |
| // Often we can't easily get current x,y,k without internal access or tracking interaction. | |
| // For now, we will skip complex persistence if the library doesn't support getter easily. | |
| // Wait, we can use graphRef.current.zoom() as a getter? | |
| // The docs say zoom(k) sets it. getter? usually yes if arg missing. | |
| try { | |
| // @ts-ignore | |
| const k = graphRef.current.zoom(); | |
| // @ts-ignore | |
| const { x, y } = graphRef.current.centerAt(); | |
| if (x !== undefined && k !== undefined) { | |
| localStorage.setItem('graph-view-state', JSON.stringify({ x, y, k })); | |
| } | |
| } catch (e) { | |
| // Ignore errors if getters fail | |
| } | |
| } | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| console.log('[GraphView] useEffect triggered, refreshTrigger:', refreshTrigger); | |
| const fetchData = async () => { | |
| try { | |
| console.log('[GraphView] Fetching graph data...'); | |
| setIsLoading(true); | |
| const graphData = await getGraphData(); | |
| console.log('[GraphView] Received graph data:', { | |
| nodes: graphData.nodes.length, | |
| links: graphData.links.length | |
| }); | |
| setData(graphData); | |
| setError(null); | |
| } catch (err) { | |
| console.error('[GraphView] Failed to load graph data:', err); | |
| setError('Failed to load graph data. Please try again.'); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| fetchData(); | |
| }, [refreshTrigger]); | |
| // Configure forces when data is loaded | |
| useEffect(() => { | |
| if (!isLoading && graphRef.current) { | |
| // Configure forces | |
| // Increase repulsion to spread out clusters | |
| graphRef.current.d3Force('charge')?.strength(-400); | |
| // Adjust link distance | |
| graphRef.current.d3Force('link')?.distance(60); | |
| // Add "valence shell" for orphans (nodes with val=1) | |
| // Pulls them to a ring at radius 300 | |
| graphRef.current.d3Force( | |
| 'valence', | |
| forceRadial(300, 0, 0).strength((node: any) => node.val === 1 ? 0.1 : 0) | |
| ); | |
| // Add collision detection to prevent overlap | |
| // @ts-ignore - d3 types might not be fully exposed | |
| if (!graphRef.current.d3Force('collide')) { | |
| // dynamic import of d3 would be needed to create new forces if not default | |
| } | |
| // Warmup the engine | |
| graphRef.current.d3ReheatSimulation(); | |
| } | |
| }, [data, isLoading]); | |
| // Calculate max connectivity for gradient normalization | |
| const maxVal = useMemo(() => { | |
| return Math.max(1, ...data.nodes.map(node => node.val || 1)); | |
| }, [data.nodes]); | |
| // Node styling based on theme and connectivity | |
| const getNodeColor = (node: any) => { | |
| const val = node.val || 1; | |
| // Normalize value 0..1 (logarithmic scale often looks better for power-law graphs) | |
| const normalized = Math.min(1, (val - 1) / (Math.max(maxVal, 2) - 1)); | |
| if (isDark) { | |
| // Dark mode: Slate-400 (#94a3b8) to Lime-300 (#bef264) | |
| // Simple linear interpolation for RGB | |
| const r = 148 + (190 - 148) * normalized; | |
| const g = 163 + (242 - 163) * normalized; | |
| const b = 184 + (100 - 184) * normalized; | |
| return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; | |
| } else { | |
| // Light mode: Slate-500 (#64748b) to Lime-500 (#84cc16) | |
| const r = 100 + (132 - 100) * normalized; | |
| const g = 116 + (204 - 116) * normalized; | |
| const b = 139 + (22 - 139) * normalized; | |
| return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; | |
| } | |
| }; | |
| const linkColor = isDark ? '#334155' : '#e2e8f0'; | |
| const backgroundColor = isDark ? '#020817' : '#ffffff'; | |
| const handleNodeClick = (node: any) => { | |
| if (node && node.id) { | |
| onSelectNote(node.id); | |
| } | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <div className="flex h-full w-full items-center justify-center bg-background text-muted-foreground"> | |
| <div className="flex flex-col items-center gap-2"> | |
| <Loader2 className="h-8 w-8 animate-spin" /> | |
| <p>Loading graph...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (error) { | |
| return ( | |
| <div className="p-8"> | |
| <Alert variant="destructive"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <AlertTitle>Error</AlertTitle> | |
| <AlertDescription>{error}</AlertDescription> | |
| </Alert> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="h-full w-full overflow-hidden bg-background"> | |
| <ForceGraph2D | |
| ref={graphRef} | |
| graphData={data} | |
| nodeLabel="label" | |
| nodeColor={getNodeColor} | |
| linkColor={() => linkColor} | |
| linkWidth={2} //width of links between nodes | |
| backgroundColor={backgroundColor} | |
| onNodeClick={handleNodeClick} | |
| nodeRelSize={6} | |
| linkDirectionalParticles={2} | |
| linkDirectionalParticleWidth={7} | |
| linkDirectionalParticleSpeed={0.0025} | |
| width={window.innerWidth * 0.75} // Approximate width, needs resize observer for true responsiveness | |
| height={window.innerHeight - 60} // Approximate height minus header | |
| /> | |
| </div> | |
| ); | |
| } | |