'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 [emptyMessage, setEmptyMessage] = useState(null);
const cameraControlsRef = useRef(null);
const hasFitCamera = useRef(false);
const hasFocusedNode = useRef(null);
// Get query params
const selectedNodeId = searchParams.get('node');
const targetUserDid = searchParams.get('user'); // For viewing someone else's galaxy
// Fetch data from API on mount and poll for updates
useEffect(() => {
async function fetchData() {
try {
// Build URL with optional user parameter
const url = targetUserDid
? `/api/galaxy?user=${encodeURIComponent(targetUserDid)}`
: '/api/galaxy';
const response = await fetch(url, {
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);
setEmptyMessage(data.message);
// If calculating, poll again in 2 seconds
if (data.message.includes('calculating')) {
setTimeout(fetchData, 2000);
}
return;
}
setNodes(data.nodes || []);
setLinks(data.links || []);
setEmptyMessage(null);
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();
}, [targetUserDid]);
// 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 (
{emptyMessage || 'Create at least 3 nodes to visualize your thought galaxy'}
{!emptyMessage && (
Nodes with content will automatically generate embeddings and 3D coordinates
)}
{targetUserDid && (
Viewing galaxy for user: {targetUserDid}
)}
);
}
return (
<>
{/* User info banner when viewing someone else's galaxy */}
{targetUserDid && (
Public Galaxy
Viewing {nodes.length} public {nodes.length === 1 ? 'node' : 'nodes'}
{targetUserDid}
)}
{/* Floating content overlay for selected node */}
{selectedNode && (
{selectedNode.title}
View on Bluesky
{selectedNode.body && (
{selectedNode.body}
)}
)}
>
);
}