This repository has no description
1import { useQueries } from '@tanstack/react-query';
2import { useState, useEffect, useRef } from 'react';
3import { graphKeys } from '../graphKeys';
4import { apiClient } from '@/api-client/ApiClient';
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 user-scoped 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 useUserGraphData(identifier: string) {
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.userPage(identifier, page),
38 queryFn: () => apiClient.getUserGraphData({ identifier, 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}