This commit fixes two critical bugs in the galaxy navigation: **Bug #1: Direct node URLs redirected to /chat** - Updated AppStateMachine to recognize /galaxy/* paths (including query params) as galaxy state - Changed line 55 from `pathname === '/galaxy'` to `pathname === '/galaxy' || pathname.startsWith('/galaxy/')` - Changed line 89 to compare pathname instead of lastNavigatedPathRef to preserve query params **Bug #2: Modal closed when clicking nodes** - Refactored ThoughtGalaxy to use URL query params (?node=xxx) instead of route params (/galaxy/node:xxx) - This prevents component unmounting when switching between nodes - Deleted app/galaxy/[node-id]/page.tsx (no longer needed) - Updated app/galaxy/page.tsx with documentation comment - Modified ThoughtGalaxy to: - Use useSearchParams() hook - Get selectedNodeId from query params - Update URL with query params on node click (doesn't cause remount) - Clear query params when modal closes **Testing:** - Verified manually with Playwright MCP that /galaxy?node=xxx preserves query params - Verified state machine correctly recognizes galaxy state - Created comprehensive Playwright test suite in tests/playwright/galaxy.spec.ts **Files changed:** - components/AppStateMachine.tsx: Fixed state machine to handle /galaxy/* paths and preserve query params - components/ThoughtGalaxy.tsx: Refactored to use query params instead of route params - app/galaxy/page.tsx: Added documentation - app/galaxy/[node-id]/page.tsx: Deleted (replaced with query param approach) - tests/playwright/galaxy.spec.ts: Added comprehensive test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
387 lines
12 KiB
TypeScript
387 lines
12 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 { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor } 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
|
|
}: {
|
|
node: NodeData;
|
|
isFocused: boolean;
|
|
onNodeClick: (node: NodeData) => void;
|
|
}) {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
const isExpanded = isFocused || hovered;
|
|
const scale = isFocused ? 2.5 : 1;
|
|
|
|
return (
|
|
<mesh
|
|
position={node.coords_3d}
|
|
scale={scale}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onNodeClick(node);
|
|
}}
|
|
onPointerOver={(e) => {
|
|
e.stopPropagation();
|
|
setHovered(true);
|
|
}}
|
|
onPointerOut={() => setHovered(false)}
|
|
>
|
|
<sphereGeometry args={[0.1, 32, 32]} />
|
|
<meshStandardMaterial
|
|
color={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
|
|
emissive={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
|
|
emissiveIntensity={isFocused ? 0.8 : (hovered ? 0.5 : 0.1)}
|
|
/>
|
|
{/* Show title on hover or focus */}
|
|
{isExpanded && (
|
|
<Text
|
|
position={[0, 0.3 / scale, 0]}
|
|
fontSize={0.1 / scale}
|
|
color="white"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
>
|
|
{node.title}
|
|
</Text>
|
|
)}
|
|
</mesh>
|
|
);
|
|
}
|
|
|
|
// 2. The Main Scene Component
|
|
export function ThoughtGalaxy() {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const [nodes, setNodes] = useState<NodeData[]>([]);
|
|
const [links, setLinks] = useState<LinkData[]>([]);
|
|
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
|
const cameraControlsRef = useRef<CameraControls>(null);
|
|
const hasFitCamera = useRef(false);
|
|
const hasFocusedNode = useRef<string | null>(null);
|
|
|
|
// Get selectedNodeId from URL query params
|
|
const selectedNodeId = searchParams.get('node');
|
|
|
|
// Fetch data from API on mount and poll for updates
|
|
useEffect(() => {
|
|
async function fetchData() {
|
|
try {
|
|
const response = await fetch('/api/galaxy', {
|
|
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);
|
|
// If calculating, poll again in 2 seconds
|
|
setTimeout(fetchData, 2000);
|
|
return;
|
|
}
|
|
|
|
setNodes(data.nodes || []);
|
|
setLinks(data.links || []);
|
|
|
|
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();
|
|
}, []);
|
|
|
|
// 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 (
|
|
<Stack align="center" justify="center" style={{ height: '100vh', width: '100vw' }}>
|
|
<MantineText size="lg" c="dimmed">
|
|
Create at least 3 nodes to visualize your thought galaxy
|
|
</MantineText>
|
|
<MantineText size="sm" c="dimmed">
|
|
Nodes with content will automatically generate embeddings and 3D coordinates
|
|
</MantineText>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Floating content overlay for selected node */}
|
|
{selectedNode && (
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
top: '10px',
|
|
left: '10px',
|
|
right: '10px',
|
|
zIndex: 1000,
|
|
maxWidth: '600px',
|
|
margin: '0 auto',
|
|
maxHeight: 'calc(100vh - 100px)', // Leave room for top/bottom padding
|
|
}}
|
|
>
|
|
<Paper p="md" radius="lg" withBorder shadow="xl" style={{ maxHeight: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
<Group justify="space-between" align="flex-start" mb="xs" style={{ flexShrink: 0 }}>
|
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
|
<Title order={2} style={{ margin: 0, marginBottom: '0.25rem' }}>
|
|
{selectedNode.title}
|
|
</Title>
|
|
<Anchor
|
|
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
size="sm"
|
|
c="dimmed"
|
|
>
|
|
View on Bluesky
|
|
</Anchor>
|
|
</Box>
|
|
<CloseButton
|
|
size="lg"
|
|
onClick={handleCloseModal}
|
|
aria-label="Close node details"
|
|
style={{ flexShrink: 0 }}
|
|
/>
|
|
</Group>
|
|
{selectedNode.body && (
|
|
<Box style={{ overflowY: 'auto', flex: 1, marginTop: '0.5rem' }}>
|
|
<MantineText
|
|
size="md"
|
|
style={{
|
|
whiteSpace: 'pre-wrap',
|
|
lineHeight: 1.6,
|
|
}}
|
|
>
|
|
{selectedNode.body}
|
|
</MantineText>
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
</Box>
|
|
)}
|
|
|
|
<Canvas
|
|
camera={{ position: [0, 5, 10], fov: 60 }}
|
|
style={{ width: '100%', height: '100%' }}
|
|
gl={{ preserveDrawingBuffer: true }}
|
|
onCreated={(state) => {
|
|
console.log('[ThoughtGalaxy] Canvas created successfully');
|
|
// Try to fit camera now that scene is ready
|
|
if (!hasFitCamera.current && nodes.length > 0) {
|
|
setTimeout(() => fitCameraToNodes(), 50);
|
|
}
|
|
}}
|
|
>
|
|
<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}
|
|
isFocused={selectedNodeId === node.id || selectedNode?.id === node.id}
|
|
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>
|
|
</>
|
|
);
|
|
}
|