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;