bigwolfe
logging
bbe02b6
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>
);
}