- Increase logo size (48x48 desktop, 56x56 mobile) for better visibility - Add logo as favicon - Add logo to mobile header - Move user menu to navigation bars (sidebar on desktop, bottom bar on mobile) - Fix desktop chat layout - container structure prevents voice controls cutoff - Fix mobile bottom bar - use icon-only ActionIcons instead of truncated text buttons - Hide Create Node/New Conversation buttons on mobile to save header space - Make fixed header and voice controls work properly with containers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
364 lines
11 KiB
Markdown
364 lines
11 KiB
Markdown
# **File: COMMIT\_11\_VIZ.md**
|
|
|
|
## **Commit 11: 3D "Thought Galaxy" Visualization**
|
|
|
|
### **Objective**
|
|
|
|
Implement the 3D "Thought Galaxy" visualization using React Three Fiber (R3F). This commit addresses **Risk 3 (UMAP Projection)** by using the "Calculate My Graph" button strategy for the hackathon.
|
|
|
|
1. Create an API route /api/calculate-graph that:
|
|
* Fetches all the user's node embeddings from SurrealDB.
|
|
* Uses umap-js to run dimensionality reduction from 1536-D down to 3-D.26
|
|
* Updates the coords\_3d field for each node in SurrealDB.
|
|
2. Create a client-side R3F component (/app/galaxy) that:
|
|
* Fetches all nodes *with* coords\_3d coordinates.
|
|
* Renders each node as a \<mesh\>.
|
|
* Renders links as \<Line\>.
|
|
* Uses \<CameraControls\> for smooth onClick interaction.
|
|
|
|
### **Implementation Specification**
|
|
|
|
**1\. Create Graph Calculation API (app/api/calculate-graph/route.ts)**
|
|
|
|
Create a file at /app/api/calculate-graph/route.ts:
|
|
|
|
TypeScript
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { cookies } from 'next/headers';
|
|
import { connectToDB } from '@/lib/db';
|
|
import { UMAP } from 'umap-js';
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const surrealJwt \= cookies().get('ponderants-auth')?.value;
|
|
if (\!surrealJwt) {
|
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
}
|
|
|
|
try {
|
|
const db \= await connectToDB(surrealJwt);
|
|
|
|
// 1\. Fetch all nodes that have an embedding but no coords
|
|
const query \= \`SELECT id, embedding FROM node WHERE embedding\!= NONE AND coords\_3d \= NONE\`;
|
|
const results \= await db.query(query);
|
|
|
|
const nodes \= results.result as { id: string; embedding: number };
|
|
|
|
if (nodes.length \< 3) {
|
|
// UMAP needs a few points to work well
|
|
return NextResponse.json({ message: 'Not enough nodes to map.' });
|
|
}
|
|
|
|
// 2\. Prepare data for UMAP
|
|
const embeddings \= nodes.map(n \=\> n.embedding);
|
|
|
|
// 3\. Run UMAP to reduce 1536-D to 3-D \[26\]
|
|
const umap \= new UMAP({
|
|
nComponents: 3,
|
|
nNeighbors: Math.min(15, nodes.length \- 1), // nNeighbors must be \< sample size
|
|
minDist: 0.1,
|
|
spread: 1.0,
|
|
});
|
|
|
|
const coords\_3d\_array \= umap.fit(embeddings);
|
|
|
|
// 4\. Update nodes in SurrealDB with their new 3D coords
|
|
// This is run in a transaction for speed.
|
|
let transaction \= 'BEGIN TRANSACTION;';
|
|
|
|
nodes.forEach((node, index) \=\> {
|
|
const coords \= coords\_3d\_array\[index\];
|
|
transaction \+= \`UPDATE ${node.id} SET coords\_3d \= \[${coords}, ${coords}, ${coords}\];\`;
|
|
});
|
|
|
|
transaction \+= 'COMMIT TRANSACTION;';
|
|
|
|
await db.query(transaction);
|
|
|
|
return NextResponse.json({ success: true, nodes\_mapped: nodes.length });
|
|
|
|
} catch (error) {
|
|
console.error('Graph calculation error:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to calculate graph' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
**2\. Create Galaxy Page (app/galaxy/page.tsx)**
|
|
|
|
Create a file at /app/galaxy/page.tsx:
|
|
|
|
TypeScript
|
|
|
|
'use client';
|
|
|
|
import { Button, Box } from '@mantine/core';
|
|
import { Suspense, useState } from 'react';
|
|
import { ThoughtGalaxy } from '@/components/ThoughtGalaxy';
|
|
|
|
export default function GalaxyPage() {
|
|
const \[isCalculating, setIsCalculating\] \= useState(false);
|
|
// This key forces a re-render of the galaxy component
|
|
const \[galaxyKey, setGalaxyKey\] \= useState(Date.now());
|
|
|
|
const handleCalculateGraph \= async () \=\> {
|
|
setIsCalculating(true);
|
|
try {
|
|
await fetch('/api/calculate-graph', { method: 'POST' });
|
|
// Refresh the galaxy component by changing its key
|
|
setGalaxyKey(Date.now());
|
|
} catch (error) {
|
|
console.error(error);
|
|
// TODO: Show notification
|
|
} finally {
|
|
setIsCalculating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
\<Box style={{ height: '100vh', width: '100vw', position: 'relative' }}\>
|
|
\<Button
|
|
onClick={handleCalculateGraph}
|
|
loading={isCalculating}
|
|
style={{ position: 'absolute', top: 20, left: 20, zIndex: 10 }}
|
|
\>
|
|
Calculate My Graph
|
|
\</Button\>
|
|
|
|
{/\* R3F Canvas for the 3D visualization \*/}
|
|
\<Suspense fallback={\<Box\>Loading 3D Scene...\</Box\>}\>
|
|
\<ThoughtGalaxy key={galaxyKey} /\>
|
|
\</SuspE\>
|
|
\</Box\>
|
|
);
|
|
}
|
|
|
|
**3\. Create 3D Component (components/ThoughtGalaxy.tsx)**
|
|
|
|
Create a file at /components/ThoughtGalaxy.tsx:
|
|
|
|
TypeScript
|
|
|
|
'use client';
|
|
|
|
import { Canvas, useLoader } from '@react-three/fiber';
|
|
import {
|
|
CameraControls,
|
|
Line,
|
|
Text,
|
|
useCursor,
|
|
} from '@react-three/drei';
|
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
|
import \* as THREE from 'three';
|
|
import { Surreal } from 'surrealdb.js';
|
|
|
|
// 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 }) {
|
|
const \[hovered, setHovered\] \= useState(false);
|
|
const \[clicked, setClicked\] \= useState(false);
|
|
useCursor(hovered);
|
|
|
|
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 (this is a hack,
|
|
// proper way is to use an API route)
|
|
const token \= document.cookie
|
|
.split('; ')
|
|
.find(row \=\> row.startsWith('ponderants-auth='))
|
|
?.split('=');
|
|
|
|
if (\!token) return;
|
|
|
|
await db.authenticate(token);
|
|
|
|
// Fetch nodes that have coordinates
|
|
const nodeResults \= await db.query(
|
|
'SELECT id, title, coords\_3d FROM node WHERE coords\_3d\!= NONE'
|
|
);
|
|
setNodes((nodeResults.result as NodeData) ||);
|
|
|
|
// Fetch links
|
|
const linkResults \= await db.query('SELECT in, out FROM links\_to');
|
|
setLinks((linkResults.result as LinkData) ||);
|
|
}
|
|
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) \=\> {
|
|
cameraControlsRef.current?.smoothTime \= 0.8;
|
|
cameraControlsRef.current?.setLookAt(
|
|
node.coords\_3d \+ 1,
|
|
node.coords\_3d \+ 1,
|
|
node.coords\_3d \+ 1,
|
|
...node.coords\_3d,
|
|
true // Enable smooth transition
|
|
);
|
|
};
|
|
|
|
return (
|
|
\<Canvas camera={{ position: , fov: 60 }}\>
|
|
\<ambientLight intensity={0.5} /\>
|
|
\<pointLight position={} 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\>
|
|
);
|
|
}
|
|
|
|
### **Test Specification**
|
|
|
|
**1\. Create Test File (tests/magnitude/11-viz.mag.ts)**
|
|
|
|
Create a file at /tests/magnitude/11-viz.mag.ts:
|
|
|
|
TypeScript
|
|
|
|
import { test } from 'magnitude-test';
|
|
|
|
// Helper function to seed the database
|
|
async function seedDatabase(agent) {
|
|
await agent.act('Seed the database with 5 nodes (A, B, C, D, E) that have embeddings but NO coordinates');
|
|
}
|
|
|
|
test('\[Happy Path\] User can calculate and view 3D graph', async (agent) \=\> {
|
|
// Setup: Seed the DB
|
|
await seedDatabase(agent);
|
|
|
|
// Act: Go to galaxy page
|
|
await agent.act('Navigate to /galaxy');
|
|
|
|
// Check: Canvas is empty (no nodes have coords yet)
|
|
await agent.check('The 3D canvas is visible');
|
|
await agent.check('The 3D canvas contains 0 node meshes');
|
|
|
|
// Act: Click the calculate button
|
|
// (Mock the /api/calculate-graph route to return success
|
|
// and trigger the component re-render)
|
|
await agent.act('Click the "Calculate My Graph" button');
|
|
|
|
// Check: Loading state appears
|
|
await agent.check('The "Calculate My Graph" button shows a loading spinner');
|
|
|
|
// (After mock API returns and component re-fetches)
|
|
// Check: The canvas now has nodes
|
|
await agent.check('The 3D canvas now contains 5 node meshes');
|
|
});
|
|
|
|
test('\[Interaction\] User can click on a node to focus', async (agent) \=\> {
|
|
// Setup: Seed the DB and pre-calculate the graph
|
|
await seedDatabase(agent);
|
|
await agent.act('Navigate to /galaxy');
|
|
await agent.act('Click the "Calculate My Graph" button');
|
|
await agent.check('The 3D canvas now contains 5 node meshes');
|
|
|
|
// Act: Click on a node
|
|
// (Magnitude can target R3F meshes by their properties)
|
|
await agent.act('Click on the 3D node mesh corresponding to "Node A"');
|
|
|
|
// Check: Camera moves
|
|
// (This is hard to check directly, but we can check
|
|
// for the side-effect: the text label appearing)
|
|
await agent.check('The camera animates and moves closer to the node');
|
|
await agent.check('A 3D text label "Node A" is visible');
|
|
});
|