'use client'; import { Canvas } from '@react-three/fiber'; import { CameraControls, Line, Text, } from '@react-three/drei'; import { Suspense, useEffect, useRef, useState } from 'react'; import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor } from '@mantine/core'; import { useRouter, usePathname } from 'next/navigation'; import * as THREE from 'three'; // Define the shape of nodes and links from API interface NodeData { id: string; title: string; body?: string; user_did: string; atp_uri: string; coords_3d: [number, number, number]; } interface LinkData { in: string; // from node id out: string; // to node id } // 1. The 3D Node Component function Node({ node, isFocused, onNodeClick }: { node: NodeData; isFocused: boolean; onNodeClick: (node: NodeData) => void; }) { const [hovered, setHovered] = useState(false); const isExpanded = isFocused || hovered; const scale = isFocused ? 2.5 : 1; return ( { e.stopPropagation(); onNodeClick(node); }} onPointerOver={(e) => { e.stopPropagation(); setHovered(true); }} onPointerOut={() => setHovered(false)} > {/* Show title on hover or focus */} {isExpanded && ( {node.title} )} ); } // 2. The Main Scene Component export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) { const router = useRouter(); const pathname = usePathname(); const [nodes, setNodes] = useState([]); const [links, setLinks] = useState([]); const [selectedNode, setSelectedNode] = useState(null); const cameraControlsRef = useRef(null); const hasFitCamera = useRef(false); const hasFocusedNode = useRef(null); // Fetch data from API on mount and poll for updates useEffect(() => { async function fetchData() { try { const response = await fetch('/api/galaxy', { credentials: 'include', // Include cookies for authentication }); if (!response.ok) { console.error('[ThoughtGalaxy] Failed to fetch galaxy data:', response.statusText); return; } const data = await response.json(); if (data.message) { console.log('[ThoughtGalaxy]', data.message); // If calculating, poll again in 2 seconds setTimeout(fetchData, 2000); return; } setNodes(data.nodes || []); setLinks(data.links || []); console.log(`[ThoughtGalaxy] Loaded ${data.nodes?.length || 0} nodes and ${data.links?.length || 0} links`); } catch (error) { console.error('[ThoughtGalaxy] Error fetching data:', error); } } fetchData(); }, []); // Function to fit camera to all nodes const fitCameraToNodes = () => { if (!cameraControlsRef.current || nodes.length === 0) { console.log('[ThoughtGalaxy] Cannot fit camera:', { hasRef: !!cameraControlsRef.current, nodesLength: nodes.length, }); return; } console.log('[ThoughtGalaxy] Fitting camera to', nodes.length, 'nodes...'); // Create a THREE.Box3 from node positions const box = new THREE.Box3(); nodes.forEach((node) => { box.expandByPoint(new THREE.Vector3( node.coords_3d[0], node.coords_3d[1], node.coords_3d[2] )); }); console.log('[ThoughtGalaxy] Bounding box:', { min: box.min, max: box.max, size: box.getSize(new THREE.Vector3()), }); // Use CameraControls' built-in fitToBox method try { cameraControlsRef.current.fitToBox( box, false, // Don't animate on initial load { paddingLeft: 0.5, paddingRight: 0.5, paddingTop: 0.5, paddingBottom: 0.5 } ); console.log('[ThoughtGalaxy] ✓ Camera fitted to bounds'); hasFitCamera.current = true; } catch (error) { console.error('[ThoughtGalaxy] Error fitting camera:', error); } }; // Fit camera when nodes change and we haven't fitted yet useEffect(() => { if (!hasFitCamera.current && nodes.length > 0 && !focusNodeId) { // Only auto-fit if we're not focusing on a specific node // Try to fit after a short delay to ensure Canvas is ready const timer = setTimeout(() => { fitCameraToNodes(); }, 100); return () => clearTimeout(timer); } }, [nodes, focusNodeId]); // Auto-focus on specific node if focusNodeId is provided useEffect(() => { if (focusNodeId && nodes.length > 0) { const focusNode = nodes.find((n) => n.id === focusNodeId); if (focusNode) { console.log('[ThoughtGalaxy] Focusing on node:', focusNodeId); // Always update selected node when focusNodeId changes (don't wait for camera ref) setSelectedNode(focusNode); // Move camera if ref is available and we haven't focused this specific node yet if (cameraControlsRef.current && (!hasFocusedNode.current || hasFocusedNode.current !== focusNodeId)) { cameraControlsRef.current.setLookAt( focusNode.coords_3d[0], focusNode.coords_3d[1], focusNode.coords_3d[2] + 2, // Position camera 2 units in front focusNode.coords_3d[0], focusNode.coords_3d[1], focusNode.coords_3d[2], hasFocusedNode.current ? true : false // Animate if not initial load ); hasFocusedNode.current = focusNodeId; } } } }, [focusNodeId, nodes]); // Map links to node positions const linkLines = links .map((link) => { const startNode = nodes.find((n) => n.id === link.in); const endNode = nodes.find((n) => n.id === link.out); if (startNode && endNode) { return { start: startNode.coords_3d, end: endNode.coords_3d, }; } return null; }) .filter(Boolean) as { start: [number, number, number]; end: [number, number, number] }[]; // Camera animation on node click const handleNodeClick = (node: NodeData) => { const targetPath = `/galaxy/${encodeURIComponent(node.id)}`; // Set selected node immediately for responsive UI setSelectedNode(node); // Only navigate if we're not already on this node's page if (pathname !== targetPath) { // Clear the focused node ref to ensure camera animates on next render hasFocusedNode.current = null; // Use replace instead of push to avoid page reload issues router.replace(targetPath); } else { // Already on this page, just animate camera to node if (cameraControlsRef.current) { cameraControlsRef.current.setLookAt( node.coords_3d[0], node.coords_3d[1], node.coords_3d[2] + 2, node.coords_3d[0], node.coords_3d[1], node.coords_3d[2], true // Animate ); } } }; console.log('[ThoughtGalaxy] Rendering with', nodes.length, 'nodes and', linkLines.length, 'link lines'); // Show message if no nodes are ready yet if (nodes.length === 0) { return ( Create at least 3 nodes to visualize your thought galaxy Nodes with content will automatically generate embeddings and 3D coordinates ); } return ( <> {/* Floating content overlay for selected node */} {selectedNode && ( {selectedNode.title} View on Bluesky setSelectedNode(null)} aria-label="Close node details" style={{ flexShrink: 0 }} /> {selectedNode.body && ( {selectedNode.body} )} )} { console.log('[ThoughtGalaxy] Canvas created successfully'); // Try to fit camera now that scene is ready if (!hasFitCamera.current && nodes.length > 0) { setTimeout(() => fitCameraToNodes(), 50); } }} > {/* Render all nodes */} {nodes.map((node) => ( ))} {/* Render all links */} {linkLines.map((line, i) => ( ))} ); }