File size: 6,782 Bytes
aca3d0b
 
044ec7f
bfedc14
aca3d0b
 
 
 
 
 
9527547
aca3d0b
 
9527547
bbe02b6
 
aca3d0b
 
 
bfedc14
aca3d0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbe02b6
aca3d0b
 
bbe02b6
aca3d0b
 
bbe02b6
 
 
 
aca3d0b
 
 
bbe02b6
aca3d0b
 
 
 
 
 
9527547
aca3d0b
044ec7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9c257b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aca3d0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9c257b
aca3d0b
a9c257b
aca3d0b
 
 
 
044ec7f
 
aca3d0b
044ec7f
aca3d0b
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
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>
  );
}