'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: () => (

Loading graph...

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

{error}

; } return (

Graph

Visualize connections between all your entities

{domains.length > 1 && ( )}
{/* Filter sidebar */}
Filters
setSearchQuery(e.target.value)} className="pl-7 text-xs" />
{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)}...

)}
)}
); }