/ src / components / DreamGraph.jsx
DreamGraph.jsx
  1  import React, { useState, useCallback, useEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react';
  2  import * as THREE from 'three';
  3  import { useThree, useFrame } from '@react-three/fiber';
  4  import DreamNode from './DreamNode';
  5  import { getRepoData } from '../utils/fileUtils';
  6  import { Quaternion, Vector3 } from 'three';
  7  
  8  // New constant for interaction types
  9  const INTERACTION_TYPES = {
 10    NODE_CLICK: 'NODE_CLICK',
 11    ESCAPE: 'ESCAPE',
 12  };
 13  
 14  const MAX_SCALE = 50; // Maximum scale for nodes
 15  const MIN_SCALE = 1; // Minimum scale for nodes
 16  const SPHERE_RADIUS = 1000; // Radius of the sphere for node positioning
 17  const DEFAULT_NODE_STATE = {
 18    liminalScaleFactor: 1,
 19    viewScaleFactor: 1,
 20    isInLiminalView: false,
 21    isFlipped: false
 22  };
 23  
 24  const calculateViewScaleFactor = (node, camera, size) => {
 25    if (node.isInLiminalView) {
 26      return node.liminalScaleFactor;
 27    }
 28    const tempV = new THREE.Vector3();
 29    tempV.copy(node.position).project(camera);
 30    const screenPosition = {
 31      x: (tempV.x * 0.5 + 0.5) * size.width,
 32      y: (tempV.y * -0.5 + 0.5) * size.height
 33    };
 34    const centerX = size.width / 2;
 35    const centerY = size.height / 2;
 36    const distanceFromCenter = Math.sqrt(
 37      (screenPosition.x - centerX) ** 2 + (screenPosition.y - centerY) ** 2
 38    );
 39    const maxDistance = Math.sqrt(centerX ** 2 + centerY ** 2);
 40    const normalizedDistance = distanceFromCenter / maxDistance;
 41    const focusedDistance = normalizedDistance * 2;
 42    const scale = MAX_SCALE * (1 - Math.min(1, focusedDistance));
 43    return Math.max(MIN_SCALE / node.baseScale, Math.min(MAX_SCALE / node.baseScale, scale));
 44  };
 45  
 46  const calculateRotation = (originalVector) => {
 47    const targetVector = new Vector3(0, 0, -1000);
 48    const normalizedOriginal = originalVector.clone().normalize();
 49    const normalizedTarget = targetVector.clone().normalize();
 50    const quaternion = new Quaternion();
 51    quaternion.setFromUnitVectors(normalizedOriginal, normalizedTarget);
 52    return quaternion;
 53  };
 54  
 55  const applyRotationToPosition = (position, rotation) => {
 56    return position.applyQuaternion(rotation).normalize().multiplyScalar(SPHERE_RADIUS);
 57  };
 58  
 59  const DreamGraph = forwardRef(({ initialNodes, onNodeRightClick, resetCamera, onHover, onFileRightClick, onNodesChange }, ref) => {
 60    const [searchTerm, setSearchTerm] = useState('');
 61    const [nodes, setNodes] = useState([]);
 62  
 63    useEffect(() => {
 64      console.log('Initial nodes received in DreamGraph:', initialNodes);
 65    }, [initialNodes]);
 66  
 67    useEffect(() => {
 68      if (onNodesChange) {
 69        const nodeNames = nodes.map(node => node.repoName);
 70        console.log('Node names being sent to App:', nodeNames);
 71        onNodesChange(nodeNames);
 72      }
 73    }, [nodes, onNodesChange]);
 74    const [isSphericalLayout, setIsSphericalLayout] = useState(true);
 75    const [centeredNode, setCenteredNode] = useState(null);
 76    const [interactionHistory, setInteractionHistory] = useState([]);
 77    const [hoveredNode, setHoveredNode] = useState(null);
 78    const { size } = useThree();
 79  
 80    const { camera } = useThree();
 81    const tempV = useRef(new THREE.Vector3());
 82  
 83  
 84    const addInteraction = useCallback((type, data, addToHistory = true) => {
 85      if (addToHistory) {
 86        setInteractionHistory(prev => [...prev, { type, data, timestamp: Date.now() }]);
 87        setRedoStack([]); // Clear the redo stack when a new action is performed
 88      }
 89    }, []);
 90  
 91    useFrame(() => {
 92      setNodes((prevNodes) =>
 93        prevNodes.map((node) => {
 94          if (!node.isInLiminalView) {
 95            const newViewScaleFactor = calculateViewScaleFactor(node, camera, size);
 96            if (Math.abs(node.viewScaleFactor - newViewScaleFactor) > 0.01) {
 97              return { ...node, viewScaleFactor: newViewScaleFactor };
 98            }
 99          }
100          return node;
101        })
102      );
103    });
104  
105    useEffect(() => {
106      const fetchNodesData = async () => {
107        const nodesData = await Promise.all(initialNodes.map(async (node) => {
108          const { metadata, dreamTalkMedia, dreamSongMedia } = await getRepoData(node.repoName);
109          return {
110            ...node,
111            metadata,
112            dreamTalkMedia,
113            dreamSongMedia,
114            baseScale: 1,
115            viewScaleFactor: 1,
116            liminalScaleFactor: 1,
117            isInLiminalView: false
118          };
119        }));
120        setNodes(nodesData);
121      };
122      fetchNodesData();
123    }, [initialNodes]);
124  
125    const displaySearchResults = useCallback((searchResults) => {
126      console.log('Search results received in DreamGraph:', searchResults);
127      const spacing = 10;
128      const unrelatedCircleRadius = 1000; // Place unrelated nodes far from view
129  
130      const honeycombPositions = (index) => {
131        if (index === 0) return [0, 0, 0];
132  
133        // Determine which ring the node is in
134        let ring = 1;
135        let indexInRing = index;
136        let totalNodesInRing = 6 * ring;
137  
138        while (indexInRing > totalNodesInRing) {
139          indexInRing -= totalNodesInRing;
140          ring += 1;
141          totalNodesInRing = 6 * ring;
142        }
143  
144        // Calculate side and position on side
145        let side = Math.floor((indexInRing - 1) / ring);
146        let positionOnSide = (indexInRing - 1) % ring;
147  
148        // Starting positions for each side in axial coordinates (q, r)
149        const startingPositions = [
150          [ring, 0],        // East
151          [0, ring],        // Northeast
152          [-ring, ring],    // Northwest
153          [-ring, 0],       // West
154          [0, -ring],       // Southwest
155          [ring, -ring],    // Southeast
156        ];
157  
158        // Direction vectors for each side in axial coordinates
159        const directions = [
160          [-1, 1],   // Side 0: NE to NW
161          [-1, 0],   // Side 1: NW to W
162          [0, -1],   // Side 2: W to SW
163          [1, -1],   // Side 3: SW to SE
164          [1, 0],    // Side 4: SE to E
165          [0, 1],    // Side 5: E to NE
166        ];
167  
168        // Compute axial coordinates (q, r)
169        let q = startingPositions[side][0] + directions[side][0] * positionOnSide;
170        let r = startingPositions[side][1] + directions[side][1] * positionOnSide;
171  
172        // Convert axial to Cartesian coordinates
173        const x = 1.5 * q;
174        const y = Math.sqrt(3) * (r + q / 2);
175  
176        return [x, y, ring];
177      };
178  
179      const calculateNodeScale = (ring) => {
180        return Math.max(0.25, 2 / (2 ** ring));
181      };
182  
183      setNodes(prevNodes => {
184        const matchedNodes = prevNodes.filter(node => 
185          searchResults.some(result => result.repoName === node.repoName)
186        );
187        const unrelatedNodes = prevNodes.filter(node => 
188          !searchResults.some(result => result.repoName === node.repoName)
189        );
190  
191        const honeycombNodes = matchedNodes.map((node, index) => {
192          const [x, y, ring] = honeycombPositions(index);
193          const scale = calculateNodeScale(ring);
194          const searchResult = searchResults.find(result => result.repoName === node.repoName);
195          return {
196            ...node,
197            position: new THREE.Vector3(x * spacing, y * spacing, 0),
198            scale: scale,
199            isInLiminalView: true,
200            liminalScaleFactor: scale,
201            viewScaleFactor: scale,
202            similarity: searchResult ? searchResult.similarity : 0
203          };
204        }).sort((a, b) => b.similarity - a.similarity);
205  
206        const unrelatedCircleNodes = unrelatedNodes.map((node, index) => {
207          const angle = (index / unrelatedNodes.length) * Math.PI * 2;
208          return {
209            ...node,
210            position: new THREE.Vector3(
211              Math.cos(angle) * unrelatedCircleRadius,
212              Math.sin(angle) * unrelatedCircleRadius,
213              0
214            ),
215            scale: 0.25,
216            isInLiminalView: true,
217            liminalScaleFactor: 0.25,
218            viewScaleFactor: 0.25,
219            similarity: 0
220          };
221        });
222  
223        return [...honeycombNodes, ...unrelatedCircleNodes];
224      });
225      setIsSphericalLayout(false);
226      setCenteredNode(null);
227    }, []);
228  
229    const positionNodesOnSphere = useCallback((centeredNodeIndex = -1) => {
230      const goldenRatio = (1 + Math.sqrt(5)) / 2;
231      
232      setNodes(prevNodes => {
233        let rotation = new Quaternion();
234  
235        if (centeredNodeIndex !== -1) {
236          const i = centeredNodeIndex + 1;
237          const phi = Math.acos(1 - 2 * i / (prevNodes.length + 1));
238          const theta = 2 * Math.PI * i / goldenRatio;
239  
240          const x = SPHERE_RADIUS * Math.sin(phi) * Math.cos(theta);
241          const y = SPHERE_RADIUS * Math.sin(phi) * Math.sin(theta);
242          const z = SPHERE_RADIUS * Math.cos(phi);
243  
244          const originalVector = new Vector3(x, y, z);
245          rotation = calculateRotation(originalVector);
246  
247        }
248  
249        return prevNodes.map((node, index) => {
250          const i = index + 1;
251          const phi = Math.acos(1 - 2 * i / (prevNodes.length + 1));
252          const theta = 2 * Math.PI * i / goldenRatio;
253  
254          const x = SPHERE_RADIUS * Math.sin(phi) * Math.cos(theta);
255          const y = SPHERE_RADIUS * Math.sin(phi) * Math.sin(theta);
256          const z = SPHERE_RADIUS * Math.cos(phi);
257  
258          const originalPosition = new Vector3(x, y, z);
259          const rotatedPosition = applyRotationToPosition(originalPosition, rotation);
260  
261  
262          return {
263            ...node,
264            ...DEFAULT_NODE_STATE,
265            position: rotatedPosition,
266            scale: 1,
267            rotation: new THREE.Euler(0, 0, 0),
268          };
269        });
270      });
271      setIsSphericalLayout(true);
272      setCenteredNode(null);
273    }, []);
274  
275    useEffect(() => {
276      const timer = setTimeout(() => {
277        requestAnimationFrame(() => {
278          positionNodesOnSphere();
279          if (resetCamera) {
280            resetCamera();
281          }
282        });
283      }, 100); // Short delay to ensure nodes are loaded
284  
285      return () => clearTimeout(timer);
286    }, [positionNodesOnSphere, resetCamera]);
287  
288    const updateNodePositions = useCallback((clickedNodeIndex) => {
289      setNodes(prevNodes => {
290        const clickedNode = prevNodes[clickedNodeIndex];
291        const otherNodes = prevNodes.filter((_, index) => index !== clickedNodeIndex);
292        
293        const relatedNodes = otherNodes.filter(node => 
294          clickedNode.metadata?.relatedNodes?.includes(node.repoName) && 
295          node.metadata?.type !== clickedNode.metadata?.type
296        );
297        const unrelatedNodes = otherNodes.filter(node => 
298          !clickedNode.metadata?.relatedNodes?.includes(node.repoName) || 
299          node.metadata?.type === clickedNode.metadata?.type
300        );
301  
302        const relatedCircleRadius = 30;
303        const unrelatedCircleRadius = 200;
304  
305        const newNodes = [
306          { 
307            ...clickedNode, 
308            position: new THREE.Vector3(0, 0, 0), 
309            liminalScaleFactor: 5, 
310            viewScaleFactor: 5,
311            isInLiminalView: true 
312          },
313          ...relatedNodes.map((node, index) => {
314            const angle = (index / relatedNodes.length) * Math.PI * 2;
315            return {
316              ...node,
317              position: new THREE.Vector3(
318                Math.cos(angle) * relatedCircleRadius,
319                Math.sin(angle) * relatedCircleRadius,
320                0
321              ),
322              liminalScaleFactor: 1,
323              viewScaleFactor: 1,
324              isInLiminalView: true
325            };
326          }),
327          ...unrelatedNodes.map((node, index) => {
328            const angle = (index / unrelatedNodes.length) * Math.PI * 2;
329            return {
330              ...node,
331              position: new THREE.Vector3(
332                Math.cos(angle) * unrelatedCircleRadius,
333                Math.sin(angle) * unrelatedCircleRadius,
334                0
335              ),
336              liminalScaleFactor: 0.5,
337              viewScaleFactor: 0.5,
338              isInLiminalView: true
339            };
340          })
341        ];
342  
343        setCenteredNode(clickedNode.repoName);
344        return newNodes;
345      });
346      setIsSphericalLayout(false);
347    }, []);
348  
349    const handleNodeClick = useCallback((clickedRepoName, addToHistory = true) => {
350      const clickedNodeIndex = nodes.findIndex(node => node.repoName === clickedRepoName);
351      if (clickedNodeIndex !== -1) {
352        // Reset the flip state of the previously centered node
353        if (centeredNode) {
354          setNodes(prevNodes => prevNodes.map(node => 
355            node.repoName === centeredNode ? { ...node, isFlipped: false } : node
356          ));
357        }
358        updateNodePositions(clickedNodeIndex);
359        if (resetCamera) {
360          resetCamera();
361        }
362        addInteraction(INTERACTION_TYPES.NODE_CLICK, { repoName: clickedRepoName }, addToHistory);
363      }
364    }, [nodes, updateNodePositions, resetCamera, centeredNode, addInteraction]);
365  
366    const handleEscape = useCallback((addToHistory = true) => {
367      addInteraction(INTERACTION_TYPES.ESCAPE, {}, addToHistory);
368      if (centeredNode) {
369        const nodeIndex = nodes.findIndex(node => node.repoName === centeredNode);
370        if (nodeIndex !== -1) {
371          positionNodesOnSphere(nodeIndex);
372          if (resetCamera) {
373            resetCamera();
374          }
375        }
376      } else if (!isSphericalLayout) {
377        positionNodesOnSphere();
378        if (resetCamera) {
379          resetCamera();
380        }
381      }
382    }, [positionNodesOnSphere, isSphericalLayout, resetCamera, centeredNode, nodes, addInteraction]);
383  
384    useEffect(() => {
385      const handleKeyDown = (event) => {
386        if (event.key === 'Escape') {
387          handleEscape();
388        }
389      };
390  
391      window.addEventListener('keydown', handleKeyDown);
392  
393      return () => {
394        window.removeEventListener('keydown', handleKeyDown);
395      };
396    }, [handleEscape]);
397  
398    const handleHover = useCallback((repoName) => {
399      setHoveredNode(repoName);
400      if (onHover) {
401        onHover(repoName);
402      }
403    }, [onHover]);
404  
405    const renderedNodes = useMemo(() => {
406      return nodes.map((node, index) => (
407        <DreamNode
408          key={node.repoName}
409          {...node}
410          scale={node.baseScale * (node.isInLiminalView ? node.liminalScaleFactor : node.viewScaleFactor)}
411          onNodeClick={handleNodeClick}
412          onNodeRightClick={onNodeRightClick}
413          onFileRightClick={onFileRightClick}
414          onHover={onHover}
415          index={index}
416          isCentered={centeredNode === node.repoName}
417          isHovered={hoveredNode === node.repoName}
418        />
419      ));
420    }, [nodes, hoveredNode, handleNodeClick, onNodeRightClick, onFileRightClick, onHover, centeredNode]);
421  
422    const [redoStack, setRedoStack] = useState([]);
423  
424    useImperativeHandle(ref, () => ({
425      handleUndo: () => {
426        setInteractionHistory(prevHistory => {
427          if (prevHistory.length < 2) {
428            return prevHistory;
429          }
430  
431          const newHistory = prevHistory.slice(0, -1);
432          const lastAction = newHistory[newHistory.length - 1];
433          const undoneAction = prevHistory[prevHistory.length - 1];
434  
435          // Execute the last action without adding to history
436          switch (lastAction.type) {
437            case INTERACTION_TYPES.NODE_CLICK:
438              handleNodeClick(lastAction.data.repoName, false);
439              break;
440            case INTERACTION_TYPES.ESCAPE:
441              handleEscape(false);
442              break;
443            default:
444              // Unknown action type
445          }
446  
447          setRedoStack(prevRedoStack => [...prevRedoStack, undoneAction]);
448  
449          return newHistory;
450        });
451      },
452      handleRedo: () => {
453        if (redoStack.length === 0) {
454          return;
455        }
456  
457        const actionToRedo = redoStack[redoStack.length - 1];
458  
459        // Execute the redo action without adding to history
460        switch (actionToRedo.type) {
461          case INTERACTION_TYPES.NODE_CLICK:
462            handleNodeClick(actionToRedo.data.repoName, false);
463            break;
464          case INTERACTION_TYPES.ESCAPE:
465            handleEscape(false);
466            break;
467          default:
468            // Unknown action type
469        }
470  
471        setInteractionHistory(prevHistory => [...prevHistory, actionToRedo]);
472        setRedoStack(prevRedoStack => prevRedoStack.slice(0, -1));
473      },
474      displaySearchResults: (searchResults) => {
475        if (searchResults.length === 0) {
476          positionNodesOnSphere();
477          setCenteredNode(null);
478        } else {
479          displaySearchResults(searchResults);
480        }
481      },
482      resetLayout: () => {
483        positionNodesOnSphere();
484        setCenteredNode(null);
485      }
486    }));
487  
488    return <>{renderedNodes}</>;
489  });
490  
491  export default DreamGraph;