Files
app/components/ThoughtGalaxy.tsx
Albert e1ee79a386 feat: Step 11 - 3D Thought Galaxy Visualization
Implements interactive 3D visualization of user's thought network using
React Three Fiber and UMAP dimensionality reduction.

Key components:
- /api/calculate-graph: UMAP projection from 768-D embeddings to 3-D coords
- /galaxy page: UI with "Calculate My Graph" button and 3D canvas
- ThoughtGalaxy component: Interactive R3F scene with nodes and links
- Magnitude tests: Comprehensive test coverage for galaxy features

Technical implementation:
- Uses umap-js for dimensionality reduction (768-D → 3-D)
- React Three Fiber for WebGL 3D rendering
- CameraControls for smooth navigation
- Client-side SurrealDB connection for fetching nodes/links
- Hackathon workaround: API uses root credentials with user DID filtering

Note: Authentication fix applied - API route uses root SurrealDB credentials
with JWT-extracted user DID filtering to maintain security while working
around JWT authentication issues in hackathon timeframe.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 02:40:50 +00:00

164 lines
4.4 KiB
TypeScript

'use client';
import { Canvas } from '@react-three/fiber';
import {
CameraControls,
Line,
Text,
} from '@react-three/drei';
import { Suspense, useEffect, useRef, useState } from 'react';
import Surreal from 'surrealdb';
// Define the shape of nodes and links from DB
interface NodeData {
id: string;
title: 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, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeData) => void }) {
const [hovered, setHovered] = useState(false);
const [clicked, setClicked] = useState(false);
return (
<mesh
position={node.coords_3d}
onClick={(e) => {
e.stopPropagation();
onNodeClick(node);
setClicked(!clicked);
}}
onPointerOver={(e) => {
e.stopPropagation();
setHovered(true);
}}
onPointerOut={() => setHovered(false)}
>
<sphereGeometry args={[0.1, 32, 32]} />
<meshStandardMaterial
color={hovered ? '#90c0ff' : '#e9ecef'}
emissive={hovered ? '#90c0ff' : '#e9ecef'}
emissiveIntensity={hovered ? 0.5 : 0.1}
/>
{/* Show title on hover or click */}
{(hovered || clicked) && (
<Text
position={[0, 0.2, 0]}
fontSize={0.1}
color="white"
anchorX="center"
anchorY="middle"
>
{node.title}
</Text>
)}
</mesh>
);
}
// 2. The Main Scene Component
export function ThoughtGalaxy() {
const [nodes, setNodes] = useState<NodeData[]>([]);
const [links, setLinks] = useState<LinkData[]>([]);
const cameraControlsRef = useRef<CameraControls>(null);
// Fetch data from SurrealDB on mount
useEffect(() => {
async function fetchData() {
// Client-side connection
const db = new Surreal();
await db.connect(process.env.NEXT_PUBLIC_SURREALDB_WSS_URL!);
// Get the token from the cookie
const tokenCookie = document.cookie
.split('; ')
.find((row) => row.startsWith('ponderants-auth='));
if (!tokenCookie) {
console.error('[ThoughtGalaxy] No auth token found');
return;
}
const token = tokenCookie.split('=')[1];
await db.authenticate(token);
// Fetch nodes that have coordinates
const nodeResults = await db.query<[NodeData[]]>(
'SELECT id, title, coords_3d FROM node WHERE coords_3d != NONE'
);
setNodes(nodeResults[0] || []);
// Fetch links
const linkResults = await db.query<[LinkData[]]>('SELECT in, out FROM links_to');
setLinks(linkResults[0] || []);
console.log(`[ThoughtGalaxy] Loaded ${nodeResults[0]?.length || 0} nodes and ${linkResults[0]?.length || 0} links`);
}
fetchData();
}, []);
// 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
const handleNodeClick = (node: NodeData) => {
if (cameraControlsRef.current) {
cameraControlsRef.current.smoothTime = 0.8;
cameraControlsRef.current.setLookAt(
node.coords_3d[0] + 1,
node.coords_3d[1] + 1,
node.coords_3d[2] + 1,
node.coords_3d[0],
node.coords_3d[1],
node.coords_3d[2],
true // Enable smooth transition
);
}
};
return (
<Canvas camera={{ position: [0, 5, 10], fov: 60 }}>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} intensity={1} />
<CameraControls ref={cameraControlsRef} />
<Suspense fallback={null}>
<group>
{/* Render all nodes */}
{nodes.map((node) => (
<Node key={node.id} node={node} onNodeClick={handleNodeClick} />
))}
{/* Render all links */}
{linkLines.map((line, i) => (
<Line
key={i}
points={[line.start, line.end]}
color="#495057" // gray
lineWidth={1}
/>
))}
</group>
</Suspense>
</Canvas>
);
}