'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, useComputedColorScheme } from '@mantine/core'; import { useRouter, usePathname, useSearchParams } 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, isDark }: { node: NodeData; isFocused: boolean; onNodeClick: (node: NodeData) => void; isDark: boolean; }) { const [hovered, setHovered] = useState(false); const isExpanded = isFocused || hovered; const scale = isFocused ? 2.5 : 1; // Theme-aware colors const nodeColor = isDark ? '#e9ecef' : '#495057'; const focusColor = '#4dabf7'; const hoverColor = '#90c0ff'; 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() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const colorScheme = useComputedColorScheme('light'); const isDark = colorScheme === 'dark'; 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); // Get selectedNodeId from URL query params const selectedNodeId = searchParams.get('node'); // 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 && !selectedNodeId) { // 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, selectedNodeId]); // Auto-focus on specific node if selectedNodeId is provided via query params useEffect(() => { if (selectedNodeId && nodes.length > 0) { const focusNode = nodes.find((n) => n.id === selectedNodeId); if (focusNode) { console.log('[ThoughtGalaxy] Focusing on node:', selectedNodeId); // Always update selected node when selectedNodeId 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 !== selectedNodeId)) { 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 = selectedNodeId; } } else { // Node ID in URL doesn't exist in data, clear it setSelectedNode(null); } } else if (!selectedNodeId && selectedNode) { // Query param was cleared but we still have a selected node in state, clear it setSelectedNode(null); hasFocusedNode.current = null; } }, [selectedNodeId, nodes, selectedNode]); // 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) => { console.log('[ThoughtGalaxy] Node clicked:', node.id); // Set selected node immediately for responsive UI setSelectedNode(node); // Update URL with query param (this won't cause remount) const params = new URLSearchParams(searchParams); params.set('node', node.id); router.replace(`${pathname}?${params.toString()}`, { scroll: false }); // Animate camera to node if (cameraControlsRef.current) { // Clear the focused node ref to ensure camera animates hasFocusedNode.current = null; 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 ); } }; // Handle closing the modal const handleCloseModal = () => { console.log('[ThoughtGalaxy] Closing modal'); setSelectedNode(null); // Remove node query param from URL const params = new URLSearchParams(searchParams); params.delete('node'); const newSearch = params.toString(); router.replace(`${pathname}${newSearch ? `?${newSearch}` : ''}`, { scroll: false }); }; 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 {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); } }} > {/* Theme-aware lighting */} {/* Render all nodes */} {nodes.map((node) => ( ))} {/* Render all links - theme-aware colors */} {linkLines.map((line, i) => ( ))} ); }