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>
164 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|