Files
app/docs/steps/step-11.md
Albert 0ed2d6c0b3 feat: Improve UI layout and navigation
- 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>
2025-11-09 14:43:11 +00:00

11 KiB

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');
});