'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter } from 'lucide-react';
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
loading: () => (
),
});
interface GraphNode {
id: string;
label: string;
type: string;
color: string;
}
interface GraphLink {
source: string;
target: string;
type: string;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
const ENTITY_TYPE_COLORS: Record = {
task: '#3b82f6',
habit: '#10b981',
project: '#8b5cf6',
note: '#f59e0b',
section: '#ec4899',
tag: '#6b7280',
domain: '#6366f1',
};
const ENTITY_TYPE_LABELS: Record = {
task: 'Tasks',
habit: 'Habits',
project: 'Projects',
note: 'Notes',
section: 'Sections',
tag: 'Tags',
domain: 'Domains',
};
export default function GraphPage() {
const router = useRouter();
const [graphData, setGraphData] = useState({ nodes: [], links: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [domainId, setDomainId] = useState(null);
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
const [filterTypes, setFilterTypes] = useState>(new Set(['task', 'habit', 'project', 'note', 'section', 'tag', 'domain']));
const [hoveredNode, setHoveredNode] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const graphRef = useRef(null);
const containerRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
// Fetch domains
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch graph data
useEffect(() => {
if (domainId) {
fetchGraphData();
}
}, [domainId]); // eslint-disable-line react-hooks/exhaustive-deps
// Update dimensions on resize
useEffect(() => {
function handleResize() {
if (containerRef.current) {
const { width, height } = containerRef.current.getBoundingClientRect();
setDimensions({ width: Math.floor(width) || 800, height: Math.floor(height) || 600 });
}
}
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
async function fetchGraphData() {
if (!domainId) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/domains/${domainId}/graph`);
if (!response.ok) throw new Error('Unable to load graph data.');
const data = await response.json();
// Convert edges to links for react-force-graph-2d
setGraphData({
nodes: data.nodes || [],
links: (data.edges || []).map((e: any) => ({
source: e.source,
target: e.target,
type: e.type,
})),
});
} catch (error) {
console.error('Failed to fetch graph data:', error);
setError('Unable to load graph data.');
} finally {
setLoading(false);
}
}
const toggleFilterType = useCallback((type: string) => {
setFilterTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}, []);
// Filter nodes and links
const filteredData = {
nodes: graphData.nodes.filter(
(n) => filterTypes.has(n.type) && (!searchQuery || n.label.toLowerCase().includes(searchQuery.toLowerCase()))
),
links: graphData.links.filter(
(l) => {
const sourceNode = graphData.nodes.find((n) => n.id === l.source);
const targetNode = graphData.nodes.find((n) => n.id === l.target);
return sourceNode && targetNode && filterTypes.has(sourceNode.type) && filterTypes.has(targetNode.type);
}
),
};
function handleNodeClick(node: any) {
const n = node as GraphNode;
switch (n.type) {
case 'task':
router.push(`/tasks?taskId=${n.id}`);
break;
case 'habit':
router.push(`/habits?habitId=${n.id}`);
break;
case 'project':
router.push(`/projects/${n.id}`);
break;
case 'note':
router.push(`/notes?noteId=${n.id}`);
break;
case 'domain':
router.push(`/dashboard?domainId=${n.id}`);
break;
default:
break;
}
}
function handleZoomIn() {
if (graphRef.current) {
const current = graphRef.current.zoom();
graphRef.current.zoom(current * 1.3, 400);
}
}
function handleZoomOut() {
if (graphRef.current) {
const current = graphRef.current.zoom();
graphRef.current.zoom(current / 1.3, 400);
}
}
function handleReset() {
if (graphRef.current) {
graphRef.current.zoomToFit(400);
}
}
if (loading && graphData.nodes.length === 0) {
return Loading graph...
;
}
if (error) {
return ;
}
return (
Graph
Visualize connections between all your entities
{domains.length > 1 && (
)}
{/* Filter sidebar */}
Filters
{Object.entries(ENTITY_TYPE_LABELS).map(([type, label]) => (
))}
{filteredData.nodes.length} nodes ยท {filteredData.links.length} edges
{/* Graph canvas */}
{filteredData.nodes.length === 0 ? (
{searchQuery ? 'No matching nodes found' : 'No graph data available'}
) : (
{
const edgeCount = filteredData.links.filter(
(e) => e.source === node.id || e.target === node.id
).length;
return Math.max(2, Math.min(edgeCount + 2, 20));
}}
linkColor={() => '#374151'}
linkWidth={0.5}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={0.99}
onNodeClick={(node: any) => handleNodeClick(node)}
onNodeHover={(node: any | null) => {
if (node) {
setHoveredNode(node as GraphNode);
} else {
setHoveredNode(null);
}
}}
width={dimensions.width}
height={dimensions.height}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
cooldownTicks={100}
warmupTicks={40}
/>
{/* Tooltip */}
{hoveredNode && (
{hoveredNode.label}
Type: {ENTITY_TYPE_LABELS[hoveredNode.type] || hoveredNode.type}
ID: {hoveredNode.id.slice(0, 8)}...
)}
)}
);
}