This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / webapp / features / graph / lib / queries / useGraphData.tsx
6.5 kB 184 lines
1import { useQueries } from '@tanstack/react-query'; 2import { useState, useEffect, useRef } from 'react'; 3import { graphKeys } from '../graphKeys'; 4import { getGraphDataPage } from '../dal'; 5import type { 6 ProcessedGraphData, 7 ExtendedGraphNode, 8 ExtendedGraphEdge, 9} from '../../types'; 10import { calculateNodeSize, getNodeColor } from '../utils/nodeStyles'; 11import type { GetGraphDataResponse } from '@semble/types'; 12 13/** 14 * Hook to fetch and process graph data with incremental loading 15 * Automatically loads data in pages and progressively renders 16 * Calculates connection counts, node sizes, and colors for all loaded data 17 */ 18export default function useGraphData() { 19 const [pagesToLoad, setPagesToLoad] = useState<number[]>([1]); 20 const [totalPages, setTotalPages] = useState<number | null>(null); 21 22 // Store processed data in state for incremental updates 23 const [processedData, setProcessedData] = useState<ProcessedGraphData>({ 24 nodes: [], 25 links: [], 26 }); 27 28 // Track which pages we've already processed to avoid reprocessing 29 const processedPagesRef = useRef<Set<number>>(new Set()); 30 31 // Maintain stable node references (important for smooth graph transitions) 32 const nodeMapRef = useRef<Map<string, ExtendedGraphNode>>(new Map()); 33 34 // Fetch pages in parallel (React Query will dedupe and cache) 35 const queries = useQueries({ 36 queries: pagesToLoad.map((page) => ({ 37 queryKey: graphKeys.page(page), 38 queryFn: () => getGraphDataPage(page), 39 staleTime: 5 * 60 * 1000, // 5 minutes 40 })), 41 }); 42 43 // Incrementally merge new pages as they complete 44 useEffect(() => { 45 const successfulQueries = queries.filter((q) => q.isSuccess && q.data); 46 if (successfulQueries.length === 0) return; 47 48 // Find pages we haven't processed yet 49 const newPages: { page: number; data: GetGraphDataResponse }[] = []; 50 successfulQueries.forEach((q, idx) => { 51 const page = pagesToLoad[idx]; 52 if (!processedPagesRef.current.has(page)) { 53 newPages.push({ page, data: q.data as GetGraphDataResponse }); 54 } 55 }); 56 57 // Update pagination metadata and queue next page 58 const latestData = successfulQueries[successfulQueries.length - 1] 59 .data as GetGraphDataResponse; 60 61 if (latestData?.pagination) { 62 setTotalPages(latestData.pagination.totalPages); 63 64 // Queue next page if available 65 if ( 66 latestData.pagination.hasMore && 67 !pagesToLoad.includes(latestData.pagination.currentPage + 1) 68 ) { 69 setPagesToLoad((prev) => [ 70 ...prev, 71 latestData.pagination.currentPage + 1, 72 ]); 73 } 74 } 75 76 // If no new pages, nothing to merge 77 if (newPages.length === 0) return; 78 79 // Mark pages as processed 80 newPages.forEach(({ page }) => processedPagesRef.current.add(page)); 81 82 // Incrementally merge new data 83 setProcessedData((prevData) => { 84 // Extract new nodes and edges from newly loaded pages 85 const newNodes = newPages.flatMap(({ data }) => data.nodes); 86 const newEdges = newPages.flatMap(({ data }) => data.edges); 87 88 // Add new nodes to map (preserving existing node references) 89 newNodes.forEach((node) => { 90 if (!nodeMapRef.current.has(node.id)) { 91 // Create new extended node with timestamp for fade-in animation 92 const extendedNode: ExtendedGraphNode = { 93 ...node, 94 connectionCount: 0, 95 val: 0, 96 color: getNodeColor(node.type), 97 // Track when node was added for smooth fade-in 98 __addedAt: Date.now(), 99 }; 100 nodeMapRef.current.set(node.id, extendedNode); 101 } 102 }); 103 104 // Combine all edges (previous + new) 105 const allEdges = [...prevData.links, ...newEdges]; 106 107 // Recalculate connection counts for ALL nodes (new edges affect existing nodes) 108 const connectionCounts: Record<string, number> = {}; 109 allEdges.forEach((edge) => { 110 // Handle both string IDs and object references (ForceGraph2D mutates edges) 111 const sourceId = 112 typeof edge.source === 'string' 113 ? edge.source 114 : (edge.source as ExtendedGraphNode).id; 115 const targetId = 116 typeof edge.target === 'string' 117 ? edge.target 118 : (edge.target as ExtendedGraphNode).id; 119 120 connectionCounts[sourceId] = (connectionCounts[sourceId] || 0) + 1; 121 connectionCounts[targetId] = (connectionCounts[targetId] || 0) + 1; 122 }); 123 124 // Update connection counts on existing node objects (mutate in place) 125 nodeMapRef.current.forEach((node) => { 126 const connectionCount = connectionCounts[node.id] || 0; 127 node.connectionCount = connectionCount; 128 node.val = calculateNodeSize(connectionCount); 129 // color stays the same 130 }); 131 132 // Get all nodes as array (stable references for existing nodes) 133 const allNodes = Array.from(nodeMapRef.current.values()); 134 135 // Create a set of valid node IDs for edge validation 136 const validNodeIds = new Set(allNodes.map((n) => n.id)); 137 138 // Process edges (add value for thickness if needed) 139 // Filter out edges that reference non-existent nodes 140 const processedEdges: ExtendedGraphEdge[] = allEdges 141 .filter((edge) => { 142 const sourceId = 143 typeof edge.source === 'string' 144 ? edge.source 145 : (edge.source as ExtendedGraphNode).id; 146 const targetId = 147 typeof edge.target === 'string' 148 ? edge.target 149 : (edge.target as ExtendedGraphNode).id; 150 return validNodeIds.has(sourceId) && validNodeIds.has(targetId); 151 }) 152 .map((edge) => ({ 153 ...edge, 154 value: 1, // Default thickness, can be customized based on edge type 155 })); 156 157 return { 158 nodes: allNodes, 159 links: processedEdges, 160 }; 161 }); 162 }, [queries, pagesToLoad]); 163 164 // Aggregate query state 165 const isLoading = queries.some((q) => q.isLoading); 166 const isError = queries.some((q) => q.isError); 167 const error = queries.find((q) => q.error)?.error; 168 169 // Calculate overall loading progress 170 const loadedPages = queries.filter((q) => q.isSuccess).length; 171 const loadingProgress = 172 totalPages && totalPages > 0 ? (loadedPages / totalPages) * 100 : 0; 173 174 return { 175 data: processedData, 176 isLoading, 177 isError, 178 error, 179 loadingProgress, 180 loadedPages, 181 totalPages, 182 isComplete: totalPages !== null && loadedPages >= totalPages, 183 }; 184}