This repository has no description
0

Configure Feed

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

Merge pull request #641 from cosmik-network/feature/sub-graph-personal-graph-20260323

Feature/sub graph personal graph 20260323

author
Wesley Finck
committer
GitHub
date (Mar 23, 2026, 5:59 PM -0700) commit 372e45f2 parent a45fad98
+3090 -142
+40 -4
src/modules/cards/application/useCases/queries/GetGraphDataUseCase.ts
··· 1 1 import { Result, ok, err } from '../../../../../shared/core/Result'; 2 2 import { UseCase } from '../../../../../shared/core/UseCase'; 3 3 import { IGraphQueryRepository } from '../../../domain/IGraphQueryRepository'; 4 + import { IIdentityResolutionService } from '../../../../atproto/domain/services/IIdentityResolutionService'; 5 + import { DIDOrHandle } from '../../../../atproto/domain/DIDOrHandle'; 4 6 5 7 export interface GetGraphDataQuery { 6 - // No parameters needed for global graph 8 + page?: number; 9 + limit?: number; 10 + identifier?: string; // Can be DID or handle 7 11 } 8 12 9 13 export interface GraphNode { ··· 30 34 export interface GetGraphDataResult { 31 35 nodes: GraphNode[]; 32 36 edges: GraphEdge[]; 37 + totalNodeCount: number; 33 38 } 34 39 35 40 export class GetGraphDataUseCase 36 41 implements UseCase<GetGraphDataQuery, Result<GetGraphDataResult>> 37 42 { 38 - constructor(private graphQueryRepo: IGraphQueryRepository) {} 43 + constructor( 44 + private graphQueryRepo: IGraphQueryRepository, 45 + private identityResolver: IIdentityResolutionService, 46 + ) {} 39 47 40 48 async execute(query: GetGraphDataQuery): Promise<Result<GetGraphDataResult>> { 41 49 try { 42 - // Fetch all graph data 43 - const graphData = await this.graphQueryRepo.getGraphData(); 50 + let userId: string | undefined = undefined; 51 + 52 + // If identifier is provided, resolve it to a DID 53 + if (query.identifier) { 54 + const identifierResult = DIDOrHandle.create(query.identifier); 55 + if (identifierResult.isErr()) { 56 + return err(new Error(`Invalid identifier: ${query.identifier}`)); 57 + } 58 + 59 + const didResult = await this.identityResolver.resolveToDID( 60 + identifierResult.value, 61 + ); 62 + if (didResult.isErr()) { 63 + return err( 64 + new Error( 65 + `Failed to resolve identifier: ${didResult.error.message}`, 66 + ), 67 + ); 68 + } 69 + 70 + userId = didResult.value.value; 71 + } 72 + 73 + // Fetch graph data with pagination and optional user scoping 74 + const graphData = await this.graphQueryRepo.getGraphData( 75 + query.page, 76 + query.limit, 77 + userId, 78 + ); 44 79 45 80 return ok({ 46 81 nodes: graphData.nodes, 47 82 edges: graphData.edges, 83 + totalNodeCount: graphData.totalNodeCount, 48 84 }); 49 85 } catch (error) { 50 86 return err(
+73
src/modules/cards/application/useCases/queries/GetUrlSubGraphUseCase.ts
··· 1 + import { Result, ok, err } from '../../../../../shared/core/Result'; 2 + import { UseCase } from '../../../../../shared/core/UseCase'; 3 + import { IGraphQueryRepository } from '../../../domain/IGraphQueryRepository'; 4 + 5 + export interface GetUrlSubGraphQuery { 6 + url: string; 7 + depth?: number; // Default 1, max 5 8 + } 9 + 10 + export interface GraphNode { 11 + id: string; 12 + type: 'USER' | 'URL' | 'COLLECTION' | 'NOTE'; 13 + label: string; 14 + metadata: Record<string, any>; 15 + } 16 + 17 + export interface GraphEdge { 18 + id: string; 19 + source: string; 20 + target: string; 21 + type: 22 + | 'USER_FOLLOWS_USER' 23 + | 'USER_FOLLOWS_COLLECTION' 24 + | 'USER_AUTHORED_URL' 25 + | 'NOTE_REFERENCES_URL' 26 + | 'COLLECTION_CONTAINS_URL' 27 + | 'URL_CONNECTS_URL'; 28 + metadata?: Record<string, any>; 29 + } 30 + 31 + export interface GetUrlSubGraphResult { 32 + nodes: GraphNode[]; 33 + edges: GraphEdge[]; 34 + totalNodeCount: number; 35 + } 36 + 37 + export class GetUrlSubGraphUseCase 38 + implements UseCase<GetUrlSubGraphQuery, Result<GetUrlSubGraphResult>> 39 + { 40 + constructor(private graphQueryRepo: IGraphQueryRepository) {} 41 + 42 + async execute( 43 + query: GetUrlSubGraphQuery, 44 + ): Promise<Result<GetUrlSubGraphResult>> { 45 + try { 46 + // Validate URL 47 + if (!query.url || query.url.trim() === '') { 48 + return err(new Error('URL is required')); 49 + } 50 + 51 + // Validate and normalize depth 52 + const depth = Math.max(1, Math.min(5, query.depth || 1)); 53 + 54 + // Fetch URL sub-graph data 55 + const graphData = await this.graphQueryRepo.getUrlSubGraph( 56 + query.url, 57 + depth, 58 + ); 59 + 60 + return ok({ 61 + nodes: graphData.nodes, 62 + edges: graphData.edges, 63 + totalNodeCount: graphData.totalNodeCount, 64 + }); 65 + } catch (error) { 66 + return err( 67 + new Error( 68 + `Failed to retrieve URL sub-graph data: ${error instanceof Error ? error.message : 'Unknown error'}`, 69 + ), 70 + ); 71 + } 72 + } 73 + }
+21 -3
src/modules/cards/domain/IGraphQueryRepository.ts
··· 23 23 export interface GraphDataDTO { 24 24 nodes: GraphNodeDTO[]; 25 25 edges: GraphEdgeDTO[]; 26 + totalNodeCount: number; 26 27 } 27 28 28 29 export interface IGraphQueryRepository { 29 30 /** 30 - * Get all nodes and edges for the global graph visualization 31 - * Returns the complete graph structure with all relationships 31 + * Get nodes and edges for graph visualization with pagination support 32 + * Returns paginated graph data with total count for calculating pagination metadata 33 + * 34 + * @param page - Page number (1-indexed, defaults to 1) 35 + * @param limit - Number of nodes per page (defaults to 300) 36 + * @param userId - Optional user DID to scope the graph to a specific user's data 32 37 */ 33 - getGraphData(): Promise<GraphDataDTO>; 38 + getGraphData( 39 + page?: number, 40 + limit?: number, 41 + userId?: string, 42 + ): Promise<GraphDataDTO>; 43 + 44 + /** 45 + * Get a sub-graph centered around a specific URL with depth-based traversal 46 + * Returns all nodes and edges within N hops of the target URL 47 + * 48 + * @param url - Target URL to center the sub-graph around 49 + * @param depth - Number of edge hops to traverse (1-5, defaults to 1) 50 + */ 51 + getUrlSubGraph(url: string, depth: number): Promise<GraphDataDTO>; 34 52 }
+26 -2
src/modules/cards/infrastructure/http/controllers/GetGraphDataController.ts
··· 10 10 11 11 async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 12 12 try { 13 - const result = await this.getGraphDataUseCase.execute({}); 13 + // Parse pagination parameters from query string 14 + const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; 15 + const limit = req.query.limit 16 + ? parseInt(req.query.limit as string, 10) 17 + : 300; 18 + 19 + const result = await this.getGraphDataUseCase.execute({ page, limit }); 14 20 15 21 if (result.isErr()) { 16 22 return this.fail(res, result.error); 17 23 } 18 24 19 - return this.ok(res, result.value); 25 + // Calculate pagination metadata 26 + const totalCount = result.value.totalNodeCount; 27 + const totalPages = Math.ceil(totalCount / limit); 28 + const hasMore = page < totalPages; 29 + 30 + // Build response with pagination 31 + const response = { 32 + nodes: result.value.nodes, 33 + edges: result.value.edges, 34 + pagination: { 35 + currentPage: page, 36 + totalPages, 37 + totalCount, 38 + hasMore, 39 + limit, 40 + }, 41 + }; 42 + 43 + return this.ok(res, response); 20 44 } catch (error: any) { 21 45 return this.handleError(res, error); 22 46 }
+48
src/modules/cards/infrastructure/http/controllers/GetUrlGraphDataController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Response } from 'express'; 3 + import { GetUrlSubGraphUseCase } from '../../../application/useCases/queries/GetUrlSubGraphUseCase'; 4 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + 6 + export class GetUrlGraphDataController extends Controller { 7 + constructor(private getUrlSubGraphUseCase: GetUrlSubGraphUseCase) { 8 + super(); 9 + } 10 + 11 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 12 + try { 13 + // Parse URL parameter from query string 14 + const url = req.query.url as string; 15 + if (!url) { 16 + return this.badRequest(res, 'URL parameter is required'); 17 + } 18 + 19 + // Parse depth parameter from query string (default 1, max 5) 20 + const depth = req.query.depth 21 + ? Math.max(1, Math.min(5, parseInt(req.query.depth as string, 10))) 22 + : 1; 23 + 24 + const result = await this.getUrlSubGraphUseCase.execute({ url, depth }); 25 + 26 + if (result.isErr()) { 27 + return this.fail(res, result.error); 28 + } 29 + 30 + // Build response without pagination (depth limits result set) 31 + const response = { 32 + nodes: result.value.nodes, 33 + edges: result.value.edges, 34 + pagination: { 35 + currentPage: 1, 36 + totalPages: 1, 37 + totalCount: result.value.totalNodeCount, 38 + hasMore: false, 39 + limit: result.value.totalNodeCount, 40 + }, 41 + }; 42 + 43 + return this.ok(res, response); 44 + } catch (error: any) { 45 + return this.handleError(res, error); 46 + } 47 + } 48 + }
+58
src/modules/cards/infrastructure/http/controllers/GetUserGraphDataController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Response } from 'express'; 3 + import { GetGraphDataUseCase } from '../../../application/useCases/queries/GetGraphDataUseCase'; 4 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + 6 + export class GetUserGraphDataController extends Controller { 7 + constructor(private getGraphDataUseCase: GetGraphDataUseCase) { 8 + super(); 9 + } 10 + 11 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 12 + try { 13 + // Extract identifier from route params 14 + const identifier = req.params.identifier; 15 + if (!identifier) { 16 + return this.badRequest(res, 'Identifier is required'); 17 + } 18 + 19 + // Parse pagination parameters from query string 20 + const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; 21 + const limit = req.query.limit 22 + ? parseInt(req.query.limit as string, 10) 23 + : 300; 24 + 25 + const result = await this.getGraphDataUseCase.execute({ 26 + page, 27 + limit, 28 + identifier, 29 + }); 30 + 31 + if (result.isErr()) { 32 + return this.fail(res, result.error); 33 + } 34 + 35 + // Calculate pagination metadata 36 + const totalCount = result.value.totalNodeCount; 37 + const totalPages = Math.ceil(totalCount / limit); 38 + const hasMore = page < totalPages; 39 + 40 + // Build response with pagination 41 + const response = { 42 + nodes: result.value.nodes, 43 + edges: result.value.edges, 44 + pagination: { 45 + currentPage: page, 46 + totalPages, 47 + totalCount, 48 + hasMore, 49 + limit, 50 + }, 51 + }; 52 + 53 + return this.ok(res, response); 54 + } catch (error: any) { 55 + return this.handleError(res, error); 56 + } 57 + } 58 + }
+14
src/modules/cards/infrastructure/http/routes/graphRoutes.ts
··· 1 1 import { Router } from 'express'; 2 2 import { GetGraphDataController } from '../controllers/GetGraphDataController'; 3 + import { GetUserGraphDataController } from '../controllers/GetUserGraphDataController'; 4 + import { GetUrlGraphDataController } from '../controllers/GetUrlGraphDataController'; 3 5 import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; 4 6 5 7 export function createGraphRoutes( 6 8 authMiddleware: AuthMiddleware, 7 9 getGraphDataController: GetGraphDataController, 10 + getUserGraphDataController: GetUserGraphDataController, 11 + getUrlGraphDataController: GetUrlGraphDataController, 8 12 ): Router { 9 13 const router = Router(); 10 14 ··· 12 16 // GET /api/graph/data - Get all nodes and edges for graph visualization 13 17 router.get('/data', authMiddleware.optionalAuth(), (req, res) => 14 18 getGraphDataController.execute(req, res), 19 + ); 20 + 21 + // GET /api/graph/user/:identifier - Get user-scoped graph data 22 + router.get('/user/:identifier', authMiddleware.optionalAuth(), (req, res) => 23 + getUserGraphDataController.execute(req, res), 24 + ); 25 + 26 + // GET /api/graph/url - Get URL-scoped sub-graph with depth-based traversal 27 + router.get('/url', authMiddleware.optionalAuth(), (req, res) => 28 + getUrlGraphDataController.execute(req, res), 15 29 ); 16 30 17 31 return router;
+13 -2
src/modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository.ts
··· 4 4 GraphDataDTO, 5 5 } from '../../domain/IGraphQueryRepository'; 6 6 import { GraphQueryService } from './query-services/GraphQueryService'; 7 + import { UrlGraphTraversalService } from './query-services/UrlGraphTraversalService'; 7 8 8 9 export class DrizzleGraphQueryRepository implements IGraphQueryRepository { 9 10 private graphQueryService: GraphQueryService; 11 + private urlGraphTraversalService: UrlGraphTraversalService; 10 12 11 13 constructor(private db: PostgresJsDatabase) { 12 14 this.graphQueryService = new GraphQueryService(db); 15 + this.urlGraphTraversalService = new UrlGraphTraversalService(db); 13 16 } 14 17 15 - async getGraphData(): Promise<GraphDataDTO> { 16 - return this.graphQueryService.getGraphData(); 18 + async getGraphData( 19 + page?: number, 20 + limit?: number, 21 + userId?: string, 22 + ): Promise<GraphDataDTO> { 23 + return this.graphQueryService.getGraphData(page, limit, userId); 24 + } 25 + 26 + async getUrlSubGraph(url: string, depth: number): Promise<GraphDataDTO> { 27 + return this.urlGraphTraversalService.getUrlSubGraph(url, depth); 17 28 } 18 29 }
+304 -43
src/modules/cards/infrastructure/repositories/query-services/GraphQueryService.ts
··· 14 14 export class GraphQueryService { 15 15 constructor(private db: PostgresJsDatabase) {} 16 16 17 - async getGraphData(): Promise<GraphDataDTO> { 17 + async getGraphData( 18 + page: number = 1, 19 + limit: number = 300, 20 + userId?: string, 21 + ): Promise<GraphDataDTO> { 18 22 // Fetch all data in parallel 19 23 const [ 20 24 userNodes, ··· 28 32 collectionUrlEdges, 29 33 urlConnectionEdges, 30 34 ] = await Promise.all([ 31 - this.getUserNodes(), 32 - this.getUrlNodes(), 33 - this.getCollectionNodes(), 34 - this.getNoteNodes(), 35 - this.getUserFollowEdges(), 36 - this.getCollectionFollowEdges(), 37 - this.getAuthorshipEdges(), 38 - this.getNoteUrlEdges(), 39 - this.getCollectionUrlEdges(), 40 - this.getUrlConnectionEdges(), 35 + this.getUserNodes(userId), 36 + this.getUrlNodes(userId), 37 + this.getCollectionNodes(userId), 38 + this.getNoteNodes(userId), 39 + this.getUserFollowEdges(userId), 40 + this.getCollectionFollowEdges(userId), 41 + this.getAuthorshipEdges(userId), 42 + this.getNoteUrlEdges(userId), 43 + this.getCollectionUrlEdges(userId), 44 + this.getUrlConnectionEdges(userId), 41 45 ]); 42 46 43 - // Combine all nodes and edges 44 - const nodes = [...userNodes, ...urlNodes, ...collectionNodes, ...noteNodes]; 47 + // Combine all nodes 48 + const allNodes = [ 49 + ...userNodes, 50 + ...urlNodes, 51 + ...collectionNodes, 52 + ...noteNodes, 53 + ]; 54 + const totalNodeCount = allNodes.length; 45 55 46 - const edges = [ 56 + // Apply pagination to nodes 57 + const offset = (page - 1) * limit; 58 + const paginatedNodes = allNodes.slice(offset, offset + limit); 59 + 60 + // Create a Set of loaded node IDs for efficient lookup 61 + const loadedNodeIds = new Set(paginatedNodes.map((node) => node.id)); 62 + 63 + // Filter edges to only include those where BOTH source and target are in loaded nodes 64 + const allEdges = [ 47 65 ...userFollowEdges, 48 66 ...collectionFollowEdges, 49 67 ...authorshipEdges, ··· 52 70 ...urlConnectionEdges, 53 71 ]; 54 72 55 - return { nodes, edges }; 73 + const filteredEdges = allEdges.filter( 74 + (edge) => 75 + loadedNodeIds.has(edge.source) && loadedNodeIds.has(edge.target), 76 + ); 77 + 78 + return { 79 + nodes: paginatedNodes, 80 + edges: filteredEdges, 81 + totalNodeCount, 82 + }; 56 83 } 57 84 58 - private async getUserNodes(): Promise<GraphNodeDTO[]> { 59 - const results = await this.db 60 - .select({ 61 - id: users.id, 62 - handle: users.handle, 63 - }) 64 - .from(users); 85 + private async getUserNodes(userId?: string): Promise<GraphNodeDTO[]> { 86 + // Query all unique DIDs from all sources in the graph 87 + // Use LEFT JOIN with users table to get handles where available 88 + // If userId is provided, filter to only include relevant DIDs 89 + const results = await this.db.execute<{ 90 + id: string; 91 + handle: string | null; 92 + }>( 93 + userId 94 + ? sql` 95 + WITH all_dids AS ( 96 + -- The target user themselves 97 + SELECT ${userId} as did 98 + UNION 99 + -- Users the target user authored cards with 100 + SELECT DISTINCT author_id as did FROM cards WHERE author_id = ${userId} 101 + UNION 102 + -- Users the target user follows 103 + SELECT DISTINCT target_id as did FROM follows 104 + WHERE follower_id = ${userId} AND target_type = 'user' 105 + UNION 106 + -- Users who follow the target user (for bidirectional context) 107 + SELECT DISTINCT follower_id as did FROM follows 108 + WHERE target_id = ${userId} AND target_type = 'user' 109 + UNION 110 + -- Curators of connections the target user created 111 + SELECT DISTINCT curator_id as did FROM connections WHERE curator_id = ${userId} 112 + UNION 113 + -- Users who contributed to the target user's collections 114 + SELECT DISTINCT added_by as did FROM collection_cards 115 + WHERE collection_id IN (SELECT id FROM collections WHERE author_id = ${userId}) 116 + UNION 117 + -- Authors of collections the target user follows 118 + SELECT DISTINCT author_id as did FROM collections 119 + WHERE id::text IN (SELECT target_id FROM follows WHERE follower_id = ${userId} AND target_type = 'collection') 120 + ) 121 + SELECT 122 + all_dids.did as id, 123 + users.handle as handle 124 + FROM all_dids 125 + LEFT JOIN users ON all_dids.did = users.id 126 + ` 127 + : sql` 128 + WITH all_dids AS ( 129 + SELECT DISTINCT author_id as did FROM cards 130 + UNION 131 + SELECT DISTINCT follower_id as did FROM follows 132 + UNION 133 + SELECT DISTINCT target_id as did FROM follows WHERE target_type = 'user' 134 + UNION 135 + SELECT DISTINCT curator_id as did FROM connections 136 + UNION 137 + SELECT DISTINCT added_by as did FROM collection_cards 138 + UNION 139 + SELECT DISTINCT author_id as did FROM collections 140 + ) 141 + SELECT 142 + all_dids.did as id, 143 + users.handle as handle 144 + FROM all_dids 145 + LEFT JOIN users ON all_dids.did = users.id 146 + `, 147 + ); 65 148 66 149 return results.map((row) => ({ 67 150 id: `user:${row.id}`, ··· 74 157 })); 75 158 } 76 159 77 - private async getUrlNodes(): Promise<GraphNodeDTO[]> { 160 + private async getUrlNodes(userId?: string): Promise<GraphNodeDTO[]> { 161 + if (userId) { 162 + // For user-scoped graph: include URLs authored by user OR in their connections 163 + const results = await this.db.execute<{ 164 + id: string; 165 + url: string; 166 + content_data: any; 167 + url_type: string | null; 168 + }>(sql` 169 + SELECT DISTINCT 170 + c.id, 171 + c.url, 172 + c.content_data, 173 + c.url_type 174 + FROM cards c 175 + WHERE c.type = 'URL' 176 + AND c.url IS NOT NULL 177 + AND ( 178 + c.author_id = ${userId} 179 + OR c.url IN ( 180 + SELECT source_value FROM connections 181 + WHERE curator_id = ${userId} AND source_type = 'URL' 182 + UNION 183 + SELECT target_value FROM connections 184 + WHERE curator_id = ${userId} AND target_type = 'URL' 185 + ) 186 + ) 187 + `); 188 + 189 + return results.map((row) => { 190 + const contentData = row.content_data as any; 191 + const title = contentData?.title || row.url || 'Untitled URL'; 192 + 193 + return { 194 + id: `url:${row.url}`, 195 + type: 'URL' as const, 196 + label: title, 197 + metadata: { 198 + cardId: row.id, 199 + url: row.url, 200 + urlType: row.url_type, 201 + title, 202 + description: contentData?.description, 203 + imageUrl: contentData?.imageUrl, 204 + }, 205 + }; 206 + }); 207 + } 208 + 209 + // Global graph: all URLs 78 210 const results = await this.db 79 211 .select({ 80 212 id: cards.id, ··· 105 237 }); 106 238 } 107 239 108 - private async getCollectionNodes(): Promise<GraphNodeDTO[]> { 240 + private async getCollectionNodes(userId?: string): Promise<GraphNodeDTO[]> { 241 + if (userId) { 242 + // For user-scoped graph: collections authored by user OR followed by user 243 + const results = await this.db.execute<{ 244 + id: string; 245 + name: string; 246 + description: string | null; 247 + author_id: string; 248 + card_count: number; 249 + }>(sql` 250 + SELECT 251 + c.id, 252 + c.name, 253 + c.description, 254 + c.author_id, 255 + c.card_count 256 + FROM collections c 257 + WHERE c.author_id = ${userId} 258 + OR c.id::text IN ( 259 + SELECT target_id FROM follows 260 + WHERE follower_id = ${userId} AND target_type = 'collection' 261 + ) 262 + `); 263 + 264 + return results.map((row) => ({ 265 + id: `collection:${row.id}`, 266 + type: 'COLLECTION' as const, 267 + label: row.name, 268 + metadata: { 269 + collectionId: row.id, 270 + name: row.name, 271 + description: row.description, 272 + authorId: row.author_id, 273 + cardCount: row.card_count, 274 + }, 275 + })); 276 + } 277 + 278 + // Global graph: all collections 109 279 const results = await this.db 110 280 .select({ 111 281 id: collections.id, ··· 130 300 })); 131 301 } 132 302 133 - private async getNoteNodes(): Promise<GraphNodeDTO[]> { 303 + private async getNoteNodes(userId?: string): Promise<GraphNodeDTO[]> { 304 + const whereConditions = [eq(cards.type, 'NOTE')]; 305 + if (userId) { 306 + whereConditions.push(eq(cards.authorId, userId)); 307 + } 308 + 134 309 const results = await this.db 135 310 .select({ 136 311 id: cards.id, ··· 138 313 authorId: cards.authorId, 139 314 }) 140 315 .from(cards) 141 - .where(eq(cards.type, 'NOTE')); 316 + .where(and(...whereConditions)); 142 317 143 318 return results.map((row) => { 144 319 const contentData = row.contentData as any; ··· 159 334 }); 160 335 } 161 336 162 - private async getUserFollowEdges(): Promise<GraphEdgeDTO[]> { 337 + private async getUserFollowEdges(userId?: string): Promise<GraphEdgeDTO[]> { 338 + const whereConditions = [eq(follows.targetType, 'user')]; 339 + if (userId) { 340 + // Include follows where user is follower OR target (bidirectional) 341 + whereConditions.push( 342 + sql`(${follows.followerId} = ${userId} OR ${follows.targetId} = ${userId})`, 343 + ); 344 + } 345 + 163 346 const results = await this.db 164 347 .select({ 165 348 followerId: follows.followerId, 166 349 targetId: follows.targetId, 167 350 }) 168 351 .from(follows) 169 - .where(eq(follows.targetType, 'user')); 352 + .where(and(...whereConditions)); 170 353 171 354 return results.map((row) => ({ 172 355 id: `follow-user:${row.followerId}:${row.targetId}`, ··· 177 360 })); 178 361 } 179 362 180 - private async getCollectionFollowEdges(): Promise<GraphEdgeDTO[]> { 363 + private async getCollectionFollowEdges( 364 + userId?: string, 365 + ): Promise<GraphEdgeDTO[]> { 366 + const whereConditions = [eq(follows.targetType, 'collection')]; 367 + if (userId) { 368 + // Only include follows by the target user 369 + whereConditions.push(eq(follows.followerId, userId)); 370 + } 371 + 181 372 const results = await this.db 182 373 .select({ 183 374 followerId: follows.followerId, 184 375 targetId: follows.targetId, 185 376 }) 186 377 .from(follows) 187 - .where(eq(follows.targetType, 'collection')); 378 + .where(and(...whereConditions)); 188 379 189 380 return results.map((row) => ({ 190 381 id: `follow-collection:${row.followerId}:${row.targetId}`, ··· 195 386 })); 196 387 } 197 388 198 - private async getAuthorshipEdges(): Promise<GraphEdgeDTO[]> { 389 + private async getAuthorshipEdges(userId?: string): Promise<GraphEdgeDTO[]> { 390 + const whereConditions = [ 391 + eq(cards.type, 'URL'), 392 + sql`${cards.url} IS NOT NULL`, 393 + ]; 394 + if (userId) { 395 + whereConditions.push(eq(cards.authorId, userId)); 396 + } 397 + 199 398 const results = await this.db 200 399 .select({ 201 400 authorId: cards.authorId, 202 401 url: cards.url, 203 402 }) 204 403 .from(cards) 205 - .where(and(eq(cards.type, 'URL'), sql`${cards.url} IS NOT NULL`)); 404 + .where(and(...whereConditions)); 206 405 207 406 return results.map((row) => ({ 208 407 id: `authorship:${row.authorId}:${row.url}`, ··· 213 412 })); 214 413 } 215 414 216 - private async getNoteUrlEdges(): Promise<GraphEdgeDTO[]> { 415 + private async getNoteUrlEdges(userId?: string): Promise<GraphEdgeDTO[]> { 217 416 // Join notes with their parent URL cards using raw SQL for self-join 218 417 const results = await this.db.execute<{ 219 418 note_id: string; 220 419 parent_url: string; 221 - }>(sql` 420 + }>( 421 + userId 422 + ? sql` 222 423 SELECT 223 424 note_cards.id as note_id, 224 425 parent_cards.url as parent_url ··· 227 428 ON note_cards.parent_card_id = parent_cards.id 228 429 AND parent_cards.type = 'URL' 229 430 WHERE note_cards.type = 'NOTE' 431 + AND note_cards.author_id = ${userId} 230 432 AND parent_cards.url IS NOT NULL 231 - `); 433 + ` 434 + : sql` 435 + SELECT 436 + note_cards.id as note_id, 437 + parent_cards.url as parent_url 438 + FROM cards as note_cards 439 + INNER JOIN cards as parent_cards 440 + ON note_cards.parent_card_id = parent_cards.id 441 + AND parent_cards.type = 'URL' 442 + WHERE note_cards.type = 'NOTE' 443 + AND parent_cards.url IS NOT NULL 444 + `, 445 + ); 232 446 233 447 return results.map((row) => ({ 234 448 id: `note-url:${row.note_id}:${row.parent_url}`, ··· 239 453 })); 240 454 } 241 455 242 - private async getCollectionUrlEdges(): Promise<GraphEdgeDTO[]> { 456 + private async getCollectionUrlEdges( 457 + userId?: string, 458 + ): Promise<GraphEdgeDTO[]> { 459 + if (userId) { 460 + // For user-scoped graph: only include collections authored by or followed by the user 461 + const results = await this.db.execute<{ 462 + collection_id: string; 463 + card_id: string; 464 + url: string; 465 + added_by: string; 466 + }>(sql` 467 + SELECT 468 + cc.collection_id, 469 + cc.card_id, 470 + c.url, 471 + cc.added_by 472 + FROM collection_cards cc 473 + INNER JOIN cards c ON cc.card_id = c.id 474 + INNER JOIN collections col ON cc.collection_id = col.id 475 + WHERE c.type = 'URL' 476 + AND c.url IS NOT NULL 477 + AND ( 478 + col.author_id = ${userId} 479 + OR col.id::text IN ( 480 + SELECT target_id FROM follows 481 + WHERE follower_id = ${userId} AND target_type = 'collection' 482 + ) 483 + ) 484 + `); 485 + 486 + return results.map((row) => ({ 487 + id: `collection-url:${row.collection_id}:${row.url}`, 488 + source: `collection:${row.collection_id}`, 489 + target: `url:${row.url}`, 490 + type: 'COLLECTION_CONTAINS_URL' as const, 491 + metadata: { 492 + addedBy: row.added_by, 493 + }, 494 + })); 495 + } 496 + 497 + // Global graph: all collection-URL edges 243 498 const results = await this.db 244 499 .select({ 245 500 collectionId: collectionCards.collectionId, ··· 262 517 })); 263 518 } 264 519 265 - private async getUrlConnectionEdges(): Promise<GraphEdgeDTO[]> { 520 + private async getUrlConnectionEdges( 521 + userId?: string, 522 + ): Promise<GraphEdgeDTO[]> { 523 + const whereConditions = [ 524 + eq(connections.sourceType, 'URL'), 525 + eq(connections.targetType, 'URL'), 526 + ]; 527 + if (userId) { 528 + // Only include connections curated by the target user 529 + whereConditions.push(eq(connections.curatorId, userId)); 530 + } 531 + 266 532 const results = await this.db 267 533 .select({ 268 534 id: connections.id, ··· 273 539 curatorId: connections.curatorId, 274 540 }) 275 541 .from(connections) 276 - .where( 277 - and( 278 - eq(connections.sourceType, 'URL'), 279 - eq(connections.targetType, 'URL'), 280 - ), 281 - ); 542 + .where(and(...whereConditions)); 282 543 283 544 return results.map((row) => ({ 284 545 id: `connection:${row.id}`,
+513
src/modules/cards/infrastructure/repositories/query-services/UrlGraphTraversalService.ts
··· 1 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 2 + import { sql } from 'drizzle-orm'; 3 + import { 4 + GraphNodeDTO, 5 + GraphEdgeDTO, 6 + GraphDataDTO, 7 + } from '../../../domain/IGraphQueryRepository'; 8 + 9 + export class UrlGraphTraversalService { 10 + constructor(private db: PostgresJsDatabase) {} 11 + 12 + async getUrlSubGraph( 13 + targetUrl: string, 14 + depth: number = 1, 15 + ): Promise<GraphDataDTO> { 16 + // Validate depth 17 + const validDepth = Math.max(1, Math.min(5, depth)); 18 + 19 + // Step 1: Find all nodes within N hops using recursive traversal 20 + const visitedNodeIds = new Set<string>(); 21 + const edges: GraphEdgeDTO[] = []; 22 + const urlsToFetch = new Set<string>([targetUrl]); 23 + const collectionsToFetch = new Set<string>(); 24 + const usersToFetch = new Set<string>(); 25 + const notesToFetch = new Set<string>(); 26 + 27 + // Traverse graph level by level (BFS approach) 28 + for (let currentDepth = 0; currentDepth < validDepth; currentDepth++) { 29 + const newUrlsToFetch = new Set<string>(); 30 + const newCollectionsToFetch = new Set<string>(); 31 + const newUsersToFetch = new Set<string>(); 32 + const newNotesToFetch = new Set<string>(); 33 + 34 + // Fetch edges for current level 35 + const currentLevelEdges = await this.getEdgesForUrls( 36 + Array.from(urlsToFetch), 37 + ); 38 + 39 + // Process edges and collect new nodes to fetch 40 + for (const edge of currentLevelEdges) { 41 + // Skip if we've already seen this edge 42 + if (edges.some((e) => e.id === edge.id)) continue; 43 + 44 + edges.push(edge); 45 + 46 + // Extract node IDs from edge source/target 47 + const [sourceType, sourceId] = this.parseNodeId(edge.source); 48 + const [targetType, targetId] = this.parseNodeId(edge.target); 49 + 50 + // Mark nodes as visited and queue for next level if needed 51 + if (!visitedNodeIds.has(edge.source)) { 52 + visitedNodeIds.add(edge.source); 53 + if (currentDepth < validDepth - 1) { 54 + this.queueNodeForFetch( 55 + sourceType, 56 + sourceId, 57 + newUrlsToFetch, 58 + newCollectionsToFetch, 59 + newUsersToFetch, 60 + newNotesToFetch, 61 + ); 62 + } 63 + } 64 + 65 + if (!visitedNodeIds.has(edge.target)) { 66 + visitedNodeIds.add(edge.target); 67 + if (currentDepth < validDepth - 1) { 68 + this.queueNodeForFetch( 69 + targetType, 70 + targetId, 71 + newUrlsToFetch, 72 + newCollectionsToFetch, 73 + newUsersToFetch, 74 + newNotesToFetch, 75 + ); 76 + } 77 + } 78 + } 79 + 80 + // Add current URLs to collections/users for fetching their edges 81 + for (const url of urlsToFetch) { 82 + // Get cards, collections, and notes for these URLs 83 + const relatedData = await this.getRelatedNodesForUrl(url); 84 + relatedData.collections.forEach((c) => collectionsToFetch.add(c)); 85 + relatedData.users.forEach((u) => usersToFetch.add(u)); 86 + relatedData.notes.forEach((n) => notesToFetch.add(n)); 87 + } 88 + 89 + // Update for next iteration 90 + urlsToFetch.clear(); 91 + newUrlsToFetch.forEach((u) => urlsToFetch.add(u)); 92 + newCollectionsToFetch.forEach((c) => collectionsToFetch.add(c)); 93 + newUsersToFetch.forEach((u) => usersToFetch.add(u)); 94 + newNotesToFetch.forEach((n) => notesToFetch.add(n)); 95 + 96 + // If no new nodes to explore, break early 97 + if ( 98 + urlsToFetch.size === 0 && 99 + collectionsToFetch.size === 0 && 100 + usersToFetch.size === 0 && 101 + notesToFetch.size === 0 102 + ) { 103 + break; 104 + } 105 + } 106 + 107 + // Step 2: Fetch all visited nodes 108 + const nodes = await this.fetchNodes( 109 + targetUrl, 110 + Array.from(visitedNodeIds), 111 + Array.from(collectionsToFetch), 112 + Array.from(usersToFetch), 113 + Array.from(notesToFetch), 114 + ); 115 + 116 + return { 117 + nodes, 118 + edges, 119 + totalNodeCount: nodes.length, 120 + }; 121 + } 122 + 123 + private parseNodeId(nodeId: string): [string, string] { 124 + const [type, ...idParts] = nodeId.split(':'); 125 + return [type || '', idParts.join(':')]; 126 + } 127 + 128 + private queueNodeForFetch( 129 + nodeType: string, 130 + nodeId: string, 131 + urls: Set<string>, 132 + collections: Set<string>, 133 + users: Set<string>, 134 + notes: Set<string>, 135 + ): void { 136 + switch (nodeType) { 137 + case 'url': 138 + urls.add(nodeId); 139 + break; 140 + case 'collection': 141 + collections.add(nodeId); 142 + break; 143 + case 'user': 144 + users.add(nodeId); 145 + break; 146 + case 'note': 147 + notes.add(nodeId); 148 + break; 149 + } 150 + } 151 + 152 + private async getEdgesForUrls(urls: string[]): Promise<GraphEdgeDTO[]> { 153 + if (urls.length === 0) return []; 154 + 155 + const edges: GraphEdgeDTO[] = []; 156 + 157 + // Build a PostgreSQL array literal 158 + const urlArrayLiteral = sql.raw( 159 + `ARRAY[${urls.map((url) => `'${url.replace(/'/g, "''")}'`).join(',')}]::text[]`, 160 + ); 161 + 162 + // Fetch URL connection edges 163 + const connectionEdges = await this.db.execute<{ 164 + id: string; 165 + source_value: string; 166 + target_value: string; 167 + connection_type: string | null; 168 + note: string | null; 169 + curator_id: string; 170 + }>(sql` 171 + SELECT 172 + id, 173 + source_value, 174 + target_value, 175 + connection_type, 176 + note, 177 + curator_id 178 + FROM connections 179 + WHERE source_type = 'URL' AND target_type = 'URL' 180 + AND (source_value = ANY(${urlArrayLiteral}) OR target_value = ANY(${urlArrayLiteral})) 181 + `); 182 + 183 + edges.push( 184 + ...connectionEdges.map((row) => ({ 185 + id: `connection:${row.id}`, 186 + source: `url:${row.source_value}`, 187 + target: `url:${row.target_value}`, 188 + type: 'URL_CONNECTS_URL' as const, 189 + metadata: { 190 + connectionType: row.connection_type, 191 + note: row.note, 192 + curatorId: row.curator_id, 193 + }, 194 + })), 195 + ); 196 + 197 + // Fetch authorship edges (user -> URL) 198 + const authorshipEdges = await this.db.execute<{ 199 + author_id: string; 200 + url: string; 201 + }>(sql` 202 + SELECT DISTINCT 203 + author_id, 204 + url 205 + FROM cards 206 + WHERE type = 'URL' 207 + AND url IS NOT NULL 208 + AND url = ANY(${urlArrayLiteral}) 209 + `); 210 + 211 + edges.push( 212 + ...authorshipEdges.map((row) => ({ 213 + id: `authorship:${row.author_id}:${row.url}`, 214 + source: `user:${row.author_id}`, 215 + target: `url:${row.url}`, 216 + type: 'USER_AUTHORED_URL' as const, 217 + metadata: {}, 218 + })), 219 + ); 220 + 221 + // Fetch collection contains URL edges 222 + const collectionUrlEdges = await this.db.execute<{ 223 + collection_id: string; 224 + url: string; 225 + added_by: string; 226 + }>(sql` 227 + SELECT 228 + cc.collection_id, 229 + c.url, 230 + cc.added_by 231 + FROM collection_cards cc 232 + INNER JOIN cards c ON cc.card_id = c.id 233 + WHERE c.type = 'URL' 234 + AND c.url IS NOT NULL 235 + AND c.url = ANY(${urlArrayLiteral}) 236 + `); 237 + 238 + edges.push( 239 + ...collectionUrlEdges.map((row) => ({ 240 + id: `collection-url:${row.collection_id}:${row.url}`, 241 + source: `collection:${row.collection_id}`, 242 + target: `url:${row.url}`, 243 + type: 'COLLECTION_CONTAINS_URL' as const, 244 + metadata: { 245 + addedBy: row.added_by, 246 + }, 247 + })), 248 + ); 249 + 250 + // Fetch note references URL edges 251 + const noteUrlEdges = await this.db.execute<{ 252 + note_id: string; 253 + parent_url: string; 254 + }>(sql` 255 + SELECT 256 + note_cards.id as note_id, 257 + parent_cards.url as parent_url 258 + FROM cards as note_cards 259 + INNER JOIN cards as parent_cards 260 + ON note_cards.parent_card_id = parent_cards.id 261 + AND parent_cards.type = 'URL' 262 + WHERE note_cards.type = 'NOTE' 263 + AND parent_cards.url IS NOT NULL 264 + AND parent_cards.url = ANY(${urlArrayLiteral}) 265 + `); 266 + 267 + edges.push( 268 + ...noteUrlEdges.map((row) => ({ 269 + id: `note-url:${row.note_id}:${row.parent_url}`, 270 + source: `note:${row.note_id}`, 271 + target: `url:${row.parent_url}`, 272 + type: 'NOTE_REFERENCES_URL' as const, 273 + metadata: {}, 274 + })), 275 + ); 276 + 277 + return edges; 278 + } 279 + 280 + private async getRelatedNodesForUrl(url: string): Promise<{ 281 + collections: string[]; 282 + users: string[]; 283 + notes: string[]; 284 + }> { 285 + // Get collections containing this URL 286 + const collections = await this.db.execute<{ collection_id: string }>(sql` 287 + SELECT DISTINCT cc.collection_id 288 + FROM collection_cards cc 289 + INNER JOIN cards c ON cc.card_id = c.id 290 + WHERE c.type = 'URL' AND c.url = ${url} 291 + `); 292 + 293 + // Get users who authored this URL 294 + const users = await this.db.execute<{ author_id: string }>(sql` 295 + SELECT DISTINCT author_id 296 + FROM cards 297 + WHERE type = 'URL' AND url = ${url} 298 + `); 299 + 300 + // Get notes for this URL 301 + const notes = await this.db.execute<{ note_id: string }>(sql` 302 + SELECT DISTINCT note_cards.id as note_id 303 + FROM cards as note_cards 304 + INNER JOIN cards as parent_cards 305 + ON note_cards.parent_card_id = parent_cards.id 306 + WHERE note_cards.type = 'NOTE' 307 + AND parent_cards.type = 'URL' 308 + AND parent_cards.url = ${url} 309 + `); 310 + 311 + return { 312 + collections: collections.map((r) => r.collection_id), 313 + users: users.map((r) => r.author_id), 314 + notes: notes.map((r) => r.note_id), 315 + }; 316 + } 317 + 318 + private async fetchNodes( 319 + targetUrl: string, 320 + visitedNodeIds: string[], 321 + collectionIds: string[], 322 + userIds: string[], 323 + noteIds: string[], 324 + ): Promise<GraphNodeDTO[]> { 325 + const nodes: GraphNodeDTO[] = []; 326 + 327 + // Extract URLs from visited node IDs 328 + const urlNodeIds = visitedNodeIds 329 + .filter((id) => id.startsWith('url:')) 330 + .map((id) => id.substring(4)); 331 + 332 + // Always include the target URL, even if it's not in the database 333 + if (!urlNodeIds.includes(targetUrl)) { 334 + urlNodeIds.push(targetUrl); 335 + } 336 + 337 + // Fetch URL nodes 338 + if (urlNodeIds.length > 0) { 339 + const urlArrayLiteral = sql.raw( 340 + `ARRAY[${urlNodeIds.map((url) => `'${url.replace(/'/g, "''")}'`).join(',')}]::text[]`, 341 + ); 342 + 343 + const urlResults = await this.db.execute<{ 344 + id: string; 345 + url: string; 346 + content_data: any; 347 + url_type: string | null; 348 + }>(sql` 349 + SELECT DISTINCT 350 + id, 351 + url, 352 + content_data, 353 + url_type 354 + FROM cards 355 + WHERE type = 'URL' 356 + AND url IS NOT NULL 357 + AND url = ANY(${urlArrayLiteral}) 358 + `); 359 + 360 + const fetchedUrls = new Set(urlResults.map((r) => r.url)); 361 + 362 + // Add fetched URL nodes 363 + nodes.push( 364 + ...urlResults.map((row) => { 365 + const contentData = row.content_data as any; 366 + const title = contentData?.title || row.url || 'Untitled URL'; 367 + 368 + return { 369 + id: `url:${row.url}`, 370 + type: 'URL' as const, 371 + label: title, 372 + metadata: { 373 + cardId: row.id, 374 + url: row.url, 375 + urlType: row.url_type, 376 + title, 377 + description: contentData?.description, 378 + imageUrl: contentData?.imageUrl, 379 + }, 380 + }; 381 + }), 382 + ); 383 + 384 + // Add synthetic nodes for URLs not in database 385 + for (const url of urlNodeIds) { 386 + if (!fetchedUrls.has(url)) { 387 + nodes.push({ 388 + id: `url:${url}`, 389 + type: 'URL' as const, 390 + label: url, 391 + metadata: { 392 + url, 393 + title: url, 394 + synthetic: true, // Mark as not in database 395 + }, 396 + }); 397 + } 398 + } 399 + } 400 + 401 + // Fetch collection nodes 402 + if (collectionIds.length > 0) { 403 + const collectionArrayLiteral = sql.raw( 404 + `ARRAY[${collectionIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]::uuid[]`, 405 + ); 406 + 407 + const collectionResults = await this.db.execute<{ 408 + id: string; 409 + name: string; 410 + description: string | null; 411 + author_id: string; 412 + card_count: number; 413 + }>(sql` 414 + SELECT 415 + id, 416 + name, 417 + description, 418 + author_id, 419 + card_count 420 + FROM collections 421 + WHERE id = ANY(${collectionArrayLiteral}) 422 + `); 423 + 424 + nodes.push( 425 + ...collectionResults.map((row) => ({ 426 + id: `collection:${row.id}`, 427 + type: 'COLLECTION' as const, 428 + label: row.name, 429 + metadata: { 430 + collectionId: row.id, 431 + name: row.name, 432 + description: row.description, 433 + authorId: row.author_id, 434 + cardCount: row.card_count, 435 + }, 436 + })), 437 + ); 438 + } 439 + 440 + // Fetch user nodes 441 + if (userIds.length > 0) { 442 + const userArrayLiteral = sql.raw( 443 + `ARRAY[${userIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]::text[]`, 444 + ); 445 + 446 + const userResults = await this.db.execute<{ 447 + id: string; 448 + handle: string | null; 449 + }>(sql` 450 + SELECT 451 + all_dids.did as id, 452 + users.handle as handle 453 + FROM (SELECT unnest(${userArrayLiteral}) as did) all_dids 454 + LEFT JOIN users ON all_dids.did = users.id 455 + `); 456 + 457 + nodes.push( 458 + ...userResults.map((row) => ({ 459 + id: `user:${row.id}`, 460 + type: 'USER' as const, 461 + label: row.handle || row.id, 462 + metadata: { 463 + did: row.id, 464 + handle: row.handle, 465 + }, 466 + })), 467 + ); 468 + } 469 + 470 + // Fetch note nodes 471 + if (noteIds.length > 0) { 472 + const noteArrayLiteral = sql.raw( 473 + `ARRAY[${noteIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]::uuid[]`, 474 + ); 475 + 476 + const noteResults = await this.db.execute<{ 477 + id: string; 478 + content_data: any; 479 + author_id: string; 480 + }>(sql` 481 + SELECT 482 + id, 483 + content_data, 484 + author_id 485 + FROM cards 486 + WHERE type = 'NOTE' 487 + AND id = ANY(${noteArrayLiteral}) 488 + `); 489 + 490 + nodes.push( 491 + ...noteResults.map((row) => { 492 + const contentData = row.content_data as any; 493 + const noteText = contentData?.note || ''; 494 + const preview = 495 + noteText.substring(0, 50) + (noteText.length > 50 ? '...' : ''); 496 + 497 + return { 498 + id: `note:${row.id}`, 499 + type: 'NOTE' as const, 500 + label: preview || 'Note', 501 + metadata: { 502 + cardId: row.id, 503 + note: noteText, 504 + authorId: row.author_id, 505 + }, 506 + }; 507 + }), 508 + ); 509 + } 510 + 511 + return nodes; 512 + } 513 + }
+9 -2
src/modules/cards/tests/utils/InMemoryGraphQueryRepository.ts
··· 16 16 return InMemoryGraphQueryRepository.instance; 17 17 } 18 18 19 - async getGraphData(): Promise<GraphDataDTO> { 19 + async getGraphData(page?: number, limit?: number): Promise<GraphDataDTO> { 20 + // For in-memory implementation, return empty graph 21 + // In a real test scenario, you would populate this with test data 22 + // and apply pagination 23 + return { nodes: [], edges: [], totalNodeCount: 0 }; 24 + } 25 + 26 + async getUrlSubGraph(url: string, depth: number): Promise<GraphDataDTO> { 20 27 // For in-memory implementation, return empty graph 21 28 // In a real test scenario, you would populate this with test data 22 - return { nodes: [], edges: [] }; 29 + return { nodes: [], edges: [], totalNodeCount: 0 }; 23 30 } 24 31 }
+3
src/shared/infrastructure/http/app.ts
··· 69 69 const controllers = ControllerFactory.create( 70 70 useCases, 71 71 services.cookieService, 72 + services, 72 73 ); 73 74 74 75 // Routes ··· 145 146 const graphRouter = createGraphRoutes( 146 147 services.authMiddleware, 147 148 controllers.getGraphDataController, 149 + controllers.getUserGraphDataController, 150 + controllers.getUrlGraphDataController, 148 151 ); 149 152 150 153 const feedRouter = createFeedRoutes(
+16 -1
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 62 62 import { GetConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetConnectionsForUrlController'; 63 63 import { SearchUrlsController } from '../../../../modules/cards/infrastructure/http/controllers/SearchUrlsController'; 64 64 import { GetGraphDataController } from '../../../../modules/cards/infrastructure/http/controllers/GetGraphDataController'; 65 + import { GetUserGraphDataController } from '../../../../modules/cards/infrastructure/http/controllers/GetUserGraphDataController'; 66 + import { GetUrlGraphDataController } from '../../../../modules/cards/infrastructure/http/controllers/GetUrlGraphDataController'; 65 67 import { CookieService } from '../services/CookieService'; 68 + import { Services } from './ServiceFactory'; 66 69 67 70 export interface Controllers { 68 71 // User controllers ··· 119 122 getConnectionsForUrlController: GetConnectionsForUrlController; 120 123 // Graph controllers 121 124 getGraphDataController: GetGraphDataController; 125 + getUserGraphDataController: GetUserGraphDataController; 126 + getUrlGraphDataController: GetUrlGraphDataController; 122 127 // Search controllers 123 128 searchUrlsController: SearchUrlsController; 124 129 // Feed controllers ··· 139 144 } 140 145 141 146 export class ControllerFactory { 142 - static create(useCases: UseCases, cookieService: CookieService): Controllers { 147 + static create( 148 + useCases: UseCases, 149 + cookieService: CookieService, 150 + services: Services, 151 + ): Controllers { 143 152 return { 144 153 // User controllers 145 154 loginWithAppPasswordController: new LoginWithAppPasswordController( ··· 309 318 // Graph controllers 310 319 getGraphDataController: new GetGraphDataController( 311 320 useCases.getGraphDataUseCase, 321 + ), 322 + getUserGraphDataController: new GetUserGraphDataController( 323 + useCases.getGraphDataUseCase, 324 + ), 325 + getUrlGraphDataController: new GetUrlGraphDataController( 326 + useCases.getUrlSubGraphUseCase, 312 327 ), 313 328 314 329 // Search controllers
+6
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 73 73 import { GetCollectionContributorsUseCase } from '../../../../modules/cards/application/useCases/queries/GetCollectionContributorsUseCase'; 74 74 import { SearchUrlsUseCase } from '../../../../modules/cards/application/useCases/queries/SearchUrlsUseCase'; 75 75 import { GetGraphDataUseCase } from '../../../../modules/cards/application/useCases/queries/GetGraphDataUseCase'; 76 + import { GetUrlSubGraphUseCase } from '../../../../modules/cards/application/useCases/queries/GetUrlSubGraphUseCase'; 76 77 77 78 export interface WorkerUseCases { 78 79 addActivityToFeedUseCase: AddActivityToFeedUseCase; ··· 149 150 deleteConnectionUseCase: DeleteConnectionUseCase; 150 151 // Graph use cases 151 152 getGraphDataUseCase: GetGraphDataUseCase; 153 + getUrlSubGraphUseCase: GetUrlSubGraphUseCase; 152 154 // Search use cases 153 155 searchUrlsUseCase: SearchUrlsUseCase; 154 156 // Feed use cases ··· 430 432 431 433 // Graph use cases 432 434 getGraphDataUseCase: new GetGraphDataUseCase( 435 + repositories.graphQueryRepository, 436 + services.identityResolutionService, 437 + ), 438 + getUrlSubGraphUseCase: new GetUrlSubGraphUseCase( 433 439 repositories.graphQueryRepository, 434 440 ), 435 441
+11 -2
src/types/src/api/requests.ts
··· 358 358 } 359 359 360 360 // Graph request types 361 - export interface GetGraphDataParams { 362 - // No parameters needed for global graph in v1 361 + export interface GetGraphDataParams extends PaginationParams { 362 + // Supports pagination for incremental graph loading 363 + } 364 + 365 + export interface GetUserGraphDataParams extends GetGraphDataParams { 366 + identifier: string; // Can be DID or handle 367 + } 368 + 369 + export interface GetUrlGraphDataParams { 370 + url: string; // Target URL to center the sub-graph around 371 + depth?: number; // Number of edge hops to traverse (1-5, defaults to 1) 363 372 }
+1
src/types/src/api/responses.ts
··· 544 544 export interface GetGraphDataResponse { 545 545 nodes: GraphNode[]; 546 546 edges: GraphEdge[]; 547 + pagination: Pagination; 547 548 }
+20 -2
src/webapp/api-client/ApiClient.ts
··· 117 117 SearchUrlsParams, 118 118 SearchUrlsResponse, 119 119 // Graph types 120 + GetGraphDataParams, 120 121 GetGraphDataResponse, 122 + GetUrlGraphDataParams, 121 123 } from '@semble/types'; 122 124 123 125 // Main API Client class using composition ··· 516 518 } 517 519 518 520 // Graph operations 519 - async getGraphData(): Promise<GetGraphDataResponse> { 520 - return this.queryClient.getGraphData(); 521 + async getGraphData( 522 + params?: GetGraphDataParams, 523 + ): Promise<GetGraphDataResponse> { 524 + return this.queryClient.getGraphData(params); 525 + } 526 + 527 + async getUserGraphData(params: { 528 + identifier: string; 529 + page?: number; 530 + limit?: number; 531 + }): Promise<GetGraphDataResponse> { 532 + return this.queryClient.getUserGraphData(params); 533 + } 534 + 535 + async getUrlGraphData( 536 + params: GetUrlGraphDataParams, 537 + ): Promise<GetGraphDataResponse> { 538 + return this.queryClient.getUrlGraphData(params); 521 539 } 522 540 } 523 541
+68 -2
src/webapp/api-client/clients/QueryClient.ts
··· 57 57 GetGraphDataResponse, 58 58 GetConnectionsForUrlParams, 59 59 GetConnectionsForUrlResponse, 60 + GetUrlGraphDataParams, 60 61 } from '@semble/types'; 61 62 62 63 export class QueryClient extends BaseClient { ··· 553 554 ); 554 555 } 555 556 557 + async getUserGraphData(params: { 558 + identifier: string; 559 + page?: number; 560 + limit?: number; 561 + }): Promise<GetGraphDataResponse> { 562 + // Build query string with pagination parameters 563 + const searchParams = new URLSearchParams(); 564 + if (params?.page) searchParams.set('page', params.page.toString()); 565 + if (params?.limit) searchParams.set('limit', params.limit.toString()); 566 + 567 + const queryString = searchParams.toString(); 568 + const endpoint = queryString 569 + ? `/api/graph/user/${params.identifier}?${queryString}` 570 + : `/api/graph/user/${params.identifier}`; 571 + 572 + return this.request<GetGraphDataResponse>('GET', endpoint); 573 + } 574 + 575 + async getUrlGraphData( 576 + params: GetUrlGraphDataParams, 577 + ): Promise<GetGraphDataResponse> { 578 + // Build query string with url and depth parameters 579 + const searchParams = new URLSearchParams(); 580 + searchParams.set('url', params.url); 581 + if (params.depth) searchParams.set('depth', params.depth.toString()); 582 + 583 + const endpoint = `/api/graph/url?${searchParams.toString()}`; 584 + 585 + return this.request<GetGraphDataResponse>('GET', endpoint); 586 + } 587 + 556 588 async getGraphData( 557 589 params?: GetGraphDataParams, 558 590 ): Promise<GetGraphDataResponse> { ··· 573 605 // Simulate network delay for realistic testing 574 606 await new Promise((resolve) => setTimeout(resolve, 500)); 575 607 576 - return mockData; 608 + // Apply pagination to mock data 609 + const page = params?.page || 1; 610 + const limit = params?.limit || 300; 611 + const offset = (page - 1) * limit; 612 + const totalCount = mockData.nodes.length; 613 + const totalPages = Math.ceil(totalCount / limit); 614 + const hasMore = page < totalPages; 615 + 616 + const paginatedNodes = mockData.nodes.slice(offset, offset + limit); 617 + const loadedNodeIds = new Set(paginatedNodes.map((n) => n.id)); 618 + const filteredEdges = mockData.edges.filter( 619 + (e) => loadedNodeIds.has(e.source) && loadedNodeIds.has(e.target), 620 + ); 621 + 622 + return { 623 + nodes: paginatedNodes, 624 + edges: filteredEdges, 625 + pagination: { 626 + currentPage: page, 627 + totalPages, 628 + totalCount, 629 + hasMore, 630 + limit, 631 + }, 632 + }; 577 633 } 578 634 579 - return this.request<GetGraphDataResponse>('GET', '/api/graph/data'); 635 + // Build query string with pagination parameters 636 + const searchParams = new URLSearchParams(); 637 + if (params?.page) searchParams.set('page', params.page.toString()); 638 + if (params?.limit) searchParams.set('limit', params.limit.toString()); 639 + 640 + const queryString = searchParams.toString(); 641 + const endpoint = queryString 642 + ? `/api/graph/data?${queryString}` 643 + : '/api/graph/data'; 644 + 645 + return this.request<GetGraphDataResponse>('GET', endpoint); 580 646 } 581 647 }
+12 -1
src/webapp/api-client/clients/mockGraphData.ts
··· 112 112 `Generated mock graph data: ${nodes.length} nodes, ${edges.length} edges`, 113 113 ); 114 114 115 - return { nodes, edges }; 115 + // Return with pagination metadata (mock assumes single page with all data) 116 + return { 117 + nodes, 118 + edges, 119 + pagination: { 120 + currentPage: 1, 121 + totalPages: 1, 122 + totalCount: nodes.length, 123 + hasMore: false, 124 + limit: nodes.length, 125 + }, 126 + }; 116 127 } 117 128 118 129 /**
+21
src/webapp/app/(dashboard)/graph/layout.tsx
··· 1 + import Header from '@/components/navigation/header/Header'; 2 + import type { Metadata } from 'next'; 3 + import { Fragment } from 'react'; 4 + 5 + export const metadata: Metadata = { 6 + title: 'Graph', 7 + description: 'Graph', 8 + }; 9 + 10 + interface Props { 11 + children: React.ReactNode; 12 + } 13 + 14 + export default function Layout(props: Props) { 15 + return ( 16 + <Fragment> 17 + <Header title="Graph" /> 18 + {props.children} 19 + </Fragment> 20 + ); 21 + }
+33
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/graph/page.tsx
··· 1 + import { Suspense } from 'react'; 2 + import { Box, LoadingOverlay } from '@mantine/core'; 3 + import UserGraphView from '@/features/graph/components/graphView/UserGraphView'; 4 + 5 + interface PageProps { 6 + params: Promise<{ 7 + handle: string; 8 + }>; 9 + } 10 + 11 + export async function generateMetadata({ params }: PageProps) { 12 + const { handle } = await params; 13 + return { 14 + title: `${handle}'s Graph | Semble`, 15 + description: `Visualize ${handle}'s knowledge graph`, 16 + }; 17 + } 18 + 19 + export default async function UserGraphPage({ params }: PageProps) { 20 + const { handle } = await params; 21 + 22 + return ( 23 + <Suspense 24 + fallback={ 25 + <Box pos="relative" h="calc(100vh - 60px)" w="100%"> 26 + <LoadingOverlay visible /> 27 + </Box> 28 + } 29 + > 30 + <UserGraphView identifier={handle} /> 31 + </Suspense> 32 + ); 33 + }
+50
src/webapp/features/graph/components/graphView/GraphFilterPanel.module.css
··· 1 + .panel { 2 + position: absolute; 3 + top: 16px; 4 + right: 16px; 5 + background: rgba(0, 0, 0, 0.85); 6 + backdrop-filter: blur(10px); 7 + border: 1px solid rgba(255, 255, 255, 0.1); 8 + border-radius: 8px; 9 + padding: 16px; 10 + z-index: 10; 11 + min-width: 240px; 12 + max-width: 280px; 13 + transition: all 0.3s ease; 14 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 15 + } 16 + 17 + .panel.collapsed { 18 + min-width: auto; 19 + padding: 8px; 20 + width: 40px; 21 + } 22 + 23 + .toggleButton { 24 + position: absolute; 25 + top: 8px; 26 + right: 8px; 27 + color: rgba(255, 255, 255, 0.7); 28 + transition: color 0.2s ease; 29 + } 30 + 31 + .toggleButton:hover { 32 + color: rgba(255, 255, 255, 1); 33 + } 34 + 35 + .panel.collapsed .toggleButton { 36 + position: static; 37 + margin: 0 auto; 38 + display: block; 39 + } 40 + 41 + .content { 42 + opacity: 1; 43 + transition: opacity 0.2s ease; 44 + padding-right: 24px; 45 + } 46 + 47 + .panel.collapsed .content { 48 + opacity: 0; 49 + pointer-events: none; 50 + }
+166
src/webapp/features/graph/components/graphView/GraphFilterPanel.tsx
··· 1 + 'use client'; 2 + 3 + import { useState } from 'react'; 4 + import { 5 + Box, 6 + Switch, 7 + Stack, 8 + Text, 9 + ActionIcon, 10 + Group, 11 + Slider, 12 + } from '@mantine/core'; 13 + import { IoChevronBack, IoChevronForward } from 'react-icons/io5'; 14 + import styles from './GraphFilterPanel.module.css'; 15 + 16 + // All possible node and edge types in the graph 17 + const NODE_TYPES = ['USER', 'COLLECTION', 'URL', 'NOTE'] as const; 18 + const EDGE_TYPES = [ 19 + 'USER_FOLLOWS_USER', 20 + 'USER_FOLLOWS_COLLECTION', 21 + 'USER_AUTHORED_URL', 22 + 'NOTE_REFERENCES_URL', 23 + 'COLLECTION_CONTAINS_URL', 24 + 'URL_CONNECTS_URL', 25 + ] as const; 26 + 27 + type NodeType = (typeof NODE_TYPES)[number]; 28 + type EdgeType = (typeof EDGE_TYPES)[number]; 29 + 30 + interface GraphFilterPanelProps { 31 + visibleNodeTypes: Set<NodeType>; 32 + visibleEdgeTypes: Set<EdgeType>; 33 + onNodeTypeToggle: (type: NodeType) => void; 34 + onEdgeTypeToggle: (type: EdgeType) => void; 35 + hiddenNodeTypeControls?: Set<NodeType>; 36 + // Optional depth control (for URL sub-graph view) 37 + depth?: number; 38 + onDepthChange?: (depth: number) => void; 39 + showDepthControl?: boolean; 40 + // Optional control for initial collapsed state 41 + defaultCollapsed?: boolean; 42 + } 43 + 44 + // Human-readable labels for types 45 + const NODE_TYPE_LABELS: Record<NodeType, string> = { 46 + USER: 'Users', 47 + COLLECTION: 'Collections', 48 + URL: 'URLs', 49 + NOTE: 'Notes', 50 + }; 51 + 52 + const EDGE_TYPE_LABELS: Record<EdgeType, string> = { 53 + USER_FOLLOWS_USER: 'User → User', 54 + USER_FOLLOWS_COLLECTION: 'User → Collection', 55 + USER_AUTHORED_URL: 'User → URL', 56 + NOTE_REFERENCES_URL: 'Note → URL', 57 + COLLECTION_CONTAINS_URL: 'Collection → URL', 58 + URL_CONNECTS_URL: 'URL → URL', 59 + }; 60 + 61 + export default function GraphFilterPanel({ 62 + visibleNodeTypes, 63 + visibleEdgeTypes, 64 + onNodeTypeToggle, 65 + onEdgeTypeToggle, 66 + hiddenNodeTypeControls = new Set(), 67 + depth, 68 + onDepthChange, 69 + showDepthControl = false, 70 + defaultCollapsed = true, 71 + }: GraphFilterPanelProps) { 72 + const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); 73 + 74 + return ( 75 + <Box className={`${styles.panel} ${isCollapsed ? styles.collapsed : ''}`}> 76 + {/* Collapse/Expand Button */} 77 + <ActionIcon 78 + className={styles.toggleButton} 79 + onClick={() => setIsCollapsed(!isCollapsed)} 80 + variant="subtle" 81 + size="sm" 82 + aria-label={isCollapsed ? 'Expand panel' : 'Collapse panel'} 83 + > 84 + {isCollapsed ? ( 85 + <IoChevronBack size={16} /> 86 + ) : ( 87 + <IoChevronForward size={16} /> 88 + )} 89 + </ActionIcon> 90 + 91 + {/* Panel Content */} 92 + {!isCollapsed && ( 93 + <Stack gap="md" className={styles.content}> 94 + <Text size="sm" fw={600} c="dimmed"> 95 + Graph Filters 96 + </Text> 97 + 98 + {/* Depth Control (only shown for URL sub-graph view) */} 99 + {showDepthControl && depth !== undefined && onDepthChange && ( 100 + <Stack gap="xs"> 101 + <Group justify="space-between"> 102 + <Text size="xs" fw={600} c="dimmed" tt="uppercase"> 103 + Depth 104 + </Text> 105 + <Text size="sm" c="dimmed"> 106 + {depth} {depth === 1 ? 'hop' : 'hops'} 107 + </Text> 108 + </Group> 109 + <Slider 110 + value={depth} 111 + onChange={onDepthChange} 112 + min={1} 113 + max={5} 114 + step={1} 115 + marks={[ 116 + { value: 1, label: '1' }, 117 + { value: 2, label: '2' }, 118 + { value: 3, label: '3' }, 119 + { value: 4, label: '4' }, 120 + { value: 5, label: '5' }, 121 + ]} 122 + size="sm" 123 + /> 124 + </Stack> 125 + )} 126 + 127 + {/* Node Type Filters */} 128 + <Stack gap="xs"> 129 + <Text size="xs" fw={600} c="dimmed" tt="uppercase"> 130 + Node Types 131 + </Text> 132 + {NODE_TYPES.filter((type) => !hiddenNodeTypeControls.has(type)).map( 133 + (type) => ( 134 + <Group key={type} justify="space-between" gap="xs"> 135 + <Text size="sm">{NODE_TYPE_LABELS[type]}</Text> 136 + <Switch 137 + checked={visibleNodeTypes.has(type)} 138 + onChange={() => onNodeTypeToggle(type)} 139 + size="sm" 140 + /> 141 + </Group> 142 + ), 143 + )} 144 + </Stack> 145 + 146 + {/* Edge Type Filters */} 147 + <Stack gap="xs"> 148 + <Text size="xs" fw={600} c="dimmed" tt="uppercase"> 149 + Edge Types 150 + </Text> 151 + {EDGE_TYPES.map((type) => ( 152 + <Group key={type} justify="space-between" gap="xs"> 153 + <Text size="sm">{EDGE_TYPE_LABELS[type]}</Text> 154 + <Switch 155 + checked={visibleEdgeTypes.has(type)} 156 + onChange={() => onEdgeTypeToggle(type)} 157 + size="sm" 158 + /> 159 + </Group> 160 + ))} 161 + </Stack> 162 + </Stack> 163 + )} 164 + </Box> 165 + ); 166 + }
+1
src/webapp/features/graph/components/graphView/GraphView.module.css
··· 1 1 .root { 2 2 cursor: grab; 3 3 user-select: none; 4 + overflow: hidden; 4 5 } 5 6 6 7 .root:active {
+190 -20
src/webapp/features/graph/components/graphView/GraphView.tsx
··· 1 1 'use client'; 2 2 3 - import { useState, useRef, useCallback } from 'react'; 3 + import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; 4 4 import dynamic from 'next/dynamic'; 5 5 import { Box, LoadingOverlay } from '@mantine/core'; 6 6 import useGraphData from '../../lib/queries/useGraphData'; ··· 21 21 } from '../../lib/utils/graphConfig'; 22 22 import NodePopupPreview from '../nodePopups/NodePopupPreview'; 23 23 import NodePopupDetail from '../nodePopups/NodePopupDetail'; 24 + import GraphFilterPanel from './GraphFilterPanel'; 24 25 import { useRouter } from 'next/navigation'; 25 26 import styles from './GraphView.module.css'; 26 27 ··· 34 35 ), 35 36 }); 36 37 38 + // Type definitions for filters 39 + type NodeType = 'USER' | 'COLLECTION' | 'URL' | 'NOTE'; 40 + type EdgeType = 41 + | 'USER_FOLLOWS_USER' 42 + | 'USER_FOLLOWS_COLLECTION' 43 + | 'USER_AUTHORED_URL' 44 + | 'NOTE_REFERENCES_URL' 45 + | 'COLLECTION_CONTAINS_URL' 46 + | 'URL_CONNECTS_URL'; 47 + 37 48 export default function GraphView() { 38 49 const router = useRouter(); 39 50 // eslint-disable-next-line @typescript-eslint/no-explicit-any ··· 45 56 const [previewPos, setPreviewPos] = useState<PopupPosition>({ x: 0, y: 0 }); 46 57 const [detailPos, setDetailPos] = useState<PopupPosition>({ x: 0, y: 0 }); 47 58 59 + // State for graph filters (all types visible by default) 60 + const [visibleNodeTypes, setVisibleNodeTypes] = useState<Set<NodeType>>( 61 + new Set(['USER', 'COLLECTION', 'URL', 'NOTE'] as NodeType[]), 62 + ); 63 + const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<Set<EdgeType>>( 64 + new Set([ 65 + 'USER_FOLLOWS_USER', 66 + 'USER_FOLLOWS_COLLECTION', 67 + 'USER_AUTHORED_URL', 68 + 'NOTE_REFERENCES_URL', 69 + 'COLLECTION_CONTAINS_URL', 70 + 'URL_CONNECTS_URL', 71 + ] as EdgeType[]), 72 + ); 73 + 48 74 // Fetch and process graph data 49 75 const { data: graphData } = useGraphData(); 50 76 77 + // Filter graph data based on visible types 78 + const filteredGraphData = useMemo(() => { 79 + if (!graphData) return null; 80 + 81 + // Filter nodes by visible types 82 + const filteredNodes = graphData.nodes.filter((node) => 83 + visibleNodeTypes.has(node.type as NodeType), 84 + ); 85 + 86 + // Create a set of visible node IDs for efficient lookup 87 + const visibleNodeIds = new Set(filteredNodes.map((node) => node.id)); 88 + 89 + // Filter edges by visible edge types AND ensure both endpoints are visible 90 + const filteredLinks = graphData.links.filter((edge) => { 91 + // Check if edge type is visible 92 + if (!visibleEdgeTypes.has(edge.type as EdgeType)) return false; 93 + 94 + // Get source and target IDs (handles both string and object references) 95 + const sourceId = 96 + typeof edge.source === 'string' 97 + ? edge.source 98 + : (edge.source as ExtendedGraphNode).id; 99 + const targetId = 100 + typeof edge.target === 'string' 101 + ? edge.target 102 + : (edge.target as ExtendedGraphNode).id; 103 + 104 + // Only include edge if both nodes are visible 105 + return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId); 106 + }); 107 + 108 + return { 109 + nodes: filteredNodes, 110 + links: filteredLinks, 111 + }; 112 + }, [graphData, visibleNodeTypes, visibleEdgeTypes]); 113 + 114 + // Toggle handlers for filter panel 115 + const handleNodeTypeToggle = useCallback((type: NodeType) => { 116 + setVisibleNodeTypes((prev) => { 117 + const newSet = new Set(prev); 118 + if (newSet.has(type)) { 119 + newSet.delete(type); 120 + } else { 121 + newSet.add(type); 122 + } 123 + return newSet; 124 + }); 125 + }, []); 126 + 127 + const handleEdgeTypeToggle = useCallback((type: EdgeType) => { 128 + setVisibleEdgeTypes((prev) => { 129 + const newSet = new Set(prev); 130 + if (newSet.has(type)) { 131 + newSet.delete(type); 132 + } else { 133 + newSet.add(type); 134 + } 135 + return newSet; 136 + }); 137 + }, []); 138 + 139 + // Track previous node count to detect new nodes and trigger smooth transitions 140 + const prevNodeCountRef = useRef(0); 141 + const animationFrameRef = useRef<number | null>(null); 142 + 143 + // Detect when new nodes are added and gently reheat simulation 144 + useEffect(() => { 145 + if (!filteredGraphData || !graphRef.current) return; 146 + 147 + const currentNodeCount = filteredGraphData.nodes.length; 148 + const hasNewNodes = currentNodeCount > prevNodeCountRef.current; 149 + 150 + if (hasNewNodes && prevNodeCountRef.current > 0) { 151 + // Gently reheat the simulation for smooth repositioning 152 + // Lower alpha = gentler movement 153 + graphRef.current.d3ReheatSimulation(); 154 + graphRef.current.d3Force('charge')?.strength(-30); // Softer repulsion during transition 155 + 156 + // Restore normal force after animation completes 157 + setTimeout(() => { 158 + if (graphRef.current?.d3Force) { 159 + graphRef.current.d3Force('charge')?.strength(-60); 160 + } 161 + }, 1000); 162 + } 163 + 164 + prevNodeCountRef.current = currentNodeCount; 165 + }, [filteredGraphData]); 166 + 167 + // Animation loop to continuously re-render for fade-in effect 168 + useEffect(() => { 169 + if (!graphData) return; 170 + 171 + const animate = () => { 172 + // Check if any nodes are still fading in (added within last 800ms) 173 + const now = Date.now(); 174 + const hasFadingNodes = graphData.nodes.some( 175 + (node) => node.__addedAt && now - node.__addedAt < 800, 176 + ); 177 + 178 + if (hasFadingNodes && graphRef.current) { 179 + // Trigger a re-render by calling refresh 180 + graphRef.current._destructor?.(); // Force canvas redraw 181 + } 182 + 183 + if (hasFadingNodes) { 184 + animationFrameRef.current = requestAnimationFrame(animate); 185 + } else { 186 + animationFrameRef.current = null; 187 + } 188 + }; 189 + 190 + // Start animation if we have new nodes 191 + if (!animationFrameRef.current) { 192 + const now = Date.now(); 193 + const hasNewNodes = graphData.nodes.some( 194 + (node) => node.__addedAt && now - node.__addedAt < 800, 195 + ); 196 + if (hasNewNodes) { 197 + animationFrameRef.current = requestAnimationFrame(animate); 198 + } 199 + } 200 + 201 + return () => { 202 + if (animationFrameRef.current) { 203 + cancelAnimationFrame(animationFrameRef.current); 204 + } 205 + }; 206 + }, [graphData]); 207 + 51 208 // Handle node hover (preview popup) 52 209 const handleNodeHover = useCallback( 53 210 (node: any) => { ··· 119 276 // Handle navigation from popups 120 277 const handleNavigate = useCallback( 121 278 (nodeId: string) => { 122 - const node = graphData?.nodes.find((n) => n.id === nodeId); 279 + const node = filteredGraphData?.nodes.find((n) => n.id === nodeId); 123 280 if (!node) return; 124 281 125 282 let route: string; ··· 142 299 143 300 router.push(route); 144 301 }, 145 - [graphData, router], 302 + [filteredGraphData, router], 146 303 ); 147 304 148 305 // Close detail popup ··· 160 317 (node.val || NODE_SIZE.DEFAULT) * (1 / Math.sqrt(globalScale)); 161 318 const isSelected = node === pinnedNode || node === hoverNode; 162 319 320 + // Calculate opacity for fade-in animation (800ms duration) 321 + let opacity = 1; 322 + if (node.__addedAt) { 323 + const age = Date.now() - node.__addedAt; 324 + const fadeDuration = 800; // ms 325 + if (age < fadeDuration) { 326 + // Ease-in opacity from 0 to 1 327 + opacity = Math.min(1, age / fadeDuration); 328 + // Ease-out cubic for smoother animation 329 + opacity = 1 - Math.pow(1 - opacity, 3); 330 + } 331 + } 332 + 333 + // Save context state for opacity 334 + ctx.save(); 335 + ctx.globalAlpha = opacity; 336 + 163 337 // Create gradient for node fill 164 338 const gradient = ctx.createRadialGradient( 165 339 node.x, ··· 199 373 // Reset shadow 200 374 ctx.shadowBlur = VISUAL_CONFIG.node.shadowBlur; 201 375 202 - // Draw label (only when zoomed in or for highly connected nodes) 203 - const shouldShowLabel = 204 - globalScale > VISUAL_CONFIG.label.minZoomToShow || 205 - (node.connectionCount && 206 - node.connectionCount >= 207 - VISUAL_CONFIG.label.minConnectionsToAlwaysShow); 208 - 209 - if (shouldShowLabel) { 210 - ctx.font = `${VISUAL_CONFIG.label.fontSize / globalScale}px ${VISUAL_CONFIG.label.fontFamily}`; 211 - ctx.fillStyle = VISUAL_CONFIG.label.color; 212 - ctx.textAlign = 'center'; 213 - ctx.textBaseline = 'middle'; 214 - ctx.fillText(node.label, node.x, node.y + size + 10 / globalScale); 215 - } 216 - 217 376 // Draw connection count badge for highly connected nodes 218 377 if (node.connectionCount && node.connectionCount >= 5) { 219 378 const badgeSize = 4 / globalScale; ··· 222 381 ctx.fillStyle = VISUAL_CONFIG.node.shadowColor; 223 382 ctx.fill(); 224 383 } 384 + 385 + // Restore context state (opacity) 386 + ctx.restore(); 225 387 }, 226 388 [pinnedNode, hoverNode], 227 389 ); ··· 242 404 [], 243 405 ); 244 406 245 - if (!graphData) { 407 + if (!filteredGraphData) { 246 408 return ( 247 409 <Box pos="relative" h="100vh" w="100%"> 248 410 <LoadingOverlay visible /> ··· 252 414 253 415 return ( 254 416 <Box pos="relative" h="calc(100vh - 60px)" w="100%" className={styles.root}> 417 + {/* Filter Panel */} 418 + <GraphFilterPanel 419 + visibleNodeTypes={visibleNodeTypes} 420 + visibleEdgeTypes={visibleEdgeTypes} 421 + onNodeTypeToggle={handleNodeTypeToggle} 422 + onEdgeTypeToggle={handleEdgeTypeToggle} 423 + /> 424 + 255 425 <ForceGraph2D 256 426 ref={graphRef} 257 - graphData={graphData} 427 + graphData={filteredGraphData} 258 428 nodeCanvasObject={nodeCanvasObject} 259 429 nodePointerAreaPaint={nodePointerAreaPaint} 260 430 onNodeHover={handleNodeHover}
+1
src/webapp/features/graph/components/graphView/GraphViewWithFeatureFlag.tsx
··· 29 29 display: 'flex', 30 30 alignItems: 'center', 31 31 justifyContent: 'center', 32 + overflow: 'hidden', 32 33 }} 33 34 > 34 35 <Stack align="center" gap="md">
+414
src/webapp/features/graph/components/graphView/UrlGraphView.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useRef, useCallback, useMemo } from 'react'; 4 + import dynamic from 'next/dynamic'; 5 + import { Box, LoadingOverlay } from '@mantine/core'; 6 + import useUrlGraphData from '../../lib/queries/useUrlGraphData'; 7 + import type { 8 + ExtendedGraphNode, 9 + ExtendedGraphEdge, 10 + PopupPosition, 11 + } from '../../types'; 12 + import { 13 + getNodeColor, 14 + getNodeSecondaryColor, 15 + NODE_SIZE, 16 + } from '../../lib/utils/nodeStyles'; 17 + import { 18 + PHYSICS_CONFIG, 19 + VISUAL_CONFIG, 20 + INTERACTION_CONFIG, 21 + } from '../../lib/utils/graphConfig'; 22 + import NodePopupPreview from '../nodePopups/NodePopupPreview'; 23 + import NodePopupDetail from '../nodePopups/NodePopupDetail'; 24 + import GraphFilterPanel from './GraphFilterPanel'; 25 + import { useRouter } from 'next/navigation'; 26 + import styles from './GraphView.module.css'; 27 + 28 + // Dynamically import ForceGraph2D to avoid SSR issues 29 + const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { 30 + ssr: false, 31 + loading: () => ( 32 + <Box pos="relative" h="100vh" w="100%"> 33 + <LoadingOverlay visible /> 34 + </Box> 35 + ), 36 + }); 37 + 38 + // Type definitions for filters 39 + type NodeType = 'USER' | 'COLLECTION' | 'URL' | 'NOTE'; 40 + type EdgeType = 41 + | 'USER_FOLLOWS_USER' 42 + | 'USER_FOLLOWS_COLLECTION' 43 + | 'USER_AUTHORED_URL' 44 + | 'NOTE_REFERENCES_URL' 45 + | 'COLLECTION_CONTAINS_URL' 46 + | 'URL_CONNECTS_URL'; 47 + 48 + interface UrlGraphViewProps { 49 + url: string; 50 + } 51 + 52 + export default function UrlGraphView({ url }: UrlGraphViewProps) { 53 + const router = useRouter(); 54 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 + const graphRef = useRef<any>(undefined); 56 + 57 + // State for depth control (default 1 hop) 58 + const [depth, setDepth] = useState<number>(1); 59 + 60 + // State for dual popup system 61 + const [hoverNode, setHoverNode] = useState<ExtendedGraphNode | null>(null); 62 + const [pinnedNode, setPinnedNode] = useState<ExtendedGraphNode | null>(null); 63 + const [previewPos, setPreviewPos] = useState<PopupPosition>({ x: 0, y: 0 }); 64 + const [detailPos, setDetailPos] = useState<PopupPosition>({ x: 0, y: 0 }); 65 + 66 + // State for graph filters (all types visible by default) 67 + const [visibleNodeTypes, setVisibleNodeTypes] = useState<Set<NodeType>>( 68 + new Set(['USER', 'COLLECTION', 'URL', 'NOTE'] as NodeType[]), 69 + ); 70 + const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<Set<EdgeType>>( 71 + new Set([ 72 + 'USER_FOLLOWS_USER', 73 + 'USER_FOLLOWS_COLLECTION', 74 + 'USER_AUTHORED_URL', 75 + 'NOTE_REFERENCES_URL', 76 + 'COLLECTION_CONTAINS_URL', 77 + 'URL_CONNECTS_URL', 78 + ] as EdgeType[]), 79 + ); 80 + 81 + // Fetch and process graph data for the URL with current depth 82 + const { data: graphData, isLoading } = useUrlGraphData(url, depth); 83 + 84 + // Filter graph data based on visible types 85 + const filteredGraphData = useMemo(() => { 86 + if (!graphData) return null; 87 + 88 + // Filter nodes by visible types 89 + const filteredNodes = graphData.nodes.filter((node) => 90 + visibleNodeTypes.has(node.type as NodeType), 91 + ); 92 + 93 + // Create a set of visible node IDs for efficient lookup 94 + const visibleNodeIds = new Set(filteredNodes.map((node) => node.id)); 95 + 96 + // Filter edges by visible edge types AND ensure both endpoints are visible 97 + const filteredLinks = graphData.links.filter((edge) => { 98 + // Check if edge type is visible 99 + if (!visibleEdgeTypes.has(edge.type as EdgeType)) return false; 100 + 101 + // Get source and target IDs (handles both string and object references) 102 + const sourceId = 103 + typeof edge.source === 'string' 104 + ? edge.source 105 + : (edge.source as ExtendedGraphNode).id; 106 + const targetId = 107 + typeof edge.target === 'string' 108 + ? edge.target 109 + : (edge.target as ExtendedGraphNode).id; 110 + 111 + // Only include edge if both nodes are visible 112 + return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId); 113 + }); 114 + 115 + return { 116 + nodes: filteredNodes, 117 + links: filteredLinks, 118 + }; 119 + }, [graphData, visibleNodeTypes, visibleEdgeTypes]); 120 + 121 + // Toggle handlers for filter panel 122 + const handleNodeTypeToggle = useCallback((type: NodeType) => { 123 + setVisibleNodeTypes((prev) => { 124 + const newSet = new Set(prev); 125 + if (newSet.has(type)) { 126 + newSet.delete(type); 127 + } else { 128 + newSet.add(type); 129 + } 130 + return newSet; 131 + }); 132 + }, []); 133 + 134 + const handleEdgeTypeToggle = useCallback((type: EdgeType) => { 135 + setVisibleEdgeTypes((prev) => { 136 + const newSet = new Set(prev); 137 + if (newSet.has(type)) { 138 + newSet.delete(type); 139 + } else { 140 + newSet.add(type); 141 + } 142 + return newSet; 143 + }); 144 + }, []); 145 + 146 + // Handle depth change 147 + const handleDepthChange = useCallback((newDepth: number) => { 148 + setDepth(newDepth); 149 + }, []); 150 + 151 + // Handle node hover (preview popup) 152 + const handleNodeHover = useCallback( 153 + (node: any) => { 154 + const typedNode = node as ExtendedGraphNode | null; 155 + if ( 156 + typedNode && 157 + !pinnedNode && 158 + typedNode.x !== undefined && 159 + typedNode.y !== undefined 160 + ) { 161 + setHoverNode(typedNode); 162 + // Calculate screen coordinates for the node 163 + if (graphRef.current) { 164 + const screenPos = graphRef.current.graph2ScreenCoords( 165 + typedNode.x, 166 + typedNode.y, 167 + ); 168 + setPreviewPos({ x: screenPos.x + 15, y: screenPos.y - 10 }); 169 + } 170 + } else if (!pinnedNode) { 171 + setHoverNode(null); 172 + } 173 + }, 174 + [pinnedNode], 175 + ); 176 + 177 + // Handle node click (detail popup) 178 + const handleNodeClick = useCallback((node: any) => { 179 + const typedNode = node as ExtendedGraphNode; 180 + setPinnedNode(typedNode); 181 + setHoverNode(null); // Hide preview when pinning detail 182 + 183 + // Convert graph coordinates to screen coordinates 184 + if ( 185 + graphRef.current && 186 + typedNode.x !== undefined && 187 + typedNode.y !== undefined 188 + ) { 189 + const screenPos = graphRef.current.graph2ScreenCoords( 190 + typedNode.x, 191 + typedNode.y, 192 + ); 193 + // Offset to the right of the node 194 + setDetailPos({ x: screenPos.x + 20, y: screenPos.y }); 195 + } 196 + }, []); 197 + 198 + // Handle background click (close detail popup) 199 + const handleBackgroundClick = useCallback(() => { 200 + setPinnedNode(null); 201 + }, []); 202 + 203 + // Update detail popup position during zoom/pan 204 + const handleZoomPan = useCallback(() => { 205 + if ( 206 + pinnedNode && 207 + graphRef.current && 208 + pinnedNode.x !== undefined && 209 + pinnedNode.y !== undefined 210 + ) { 211 + const screenPos = graphRef.current.graph2ScreenCoords( 212 + pinnedNode.x, 213 + pinnedNode.y, 214 + ); 215 + setDetailPos({ x: screenPos.x + 20, y: screenPos.y }); 216 + } 217 + }, [pinnedNode]); 218 + 219 + // Handle navigation from popups 220 + const handleNavigate = useCallback( 221 + (nodeId: string) => { 222 + const node = filteredGraphData?.nodes.find((n) => n.id === nodeId); 223 + if (!node) return; 224 + 225 + let route: string; 226 + switch (node.type) { 227 + case 'USER': 228 + route = `/profile/${node.metadata.handle}`; 229 + break; 230 + case 'COLLECTION': 231 + route = `/collections/${node.metadata.handle}/${node.metadata.rkey}`; 232 + break; 233 + case 'URL': 234 + route = `/url?id=${encodeURIComponent(node.metadata.url)}`; 235 + break; 236 + case 'NOTE': 237 + route = `/url?id=${encodeURIComponent(node.metadata.parentUrl)}`; 238 + break; 239 + default: 240 + return; 241 + } 242 + 243 + router.push(route); 244 + }, 245 + [filteredGraphData, router], 246 + ); 247 + 248 + // Close detail popup 249 + const handleCloseDetail = useCallback(() => { 250 + setPinnedNode(null); 251 + }, []); 252 + 253 + // Custom node canvas renderer 254 + const nodeCanvasObject = useCallback( 255 + (nodeData: any, ctx: CanvasRenderingContext2D, globalScale: number) => { 256 + const node = nodeData as ExtendedGraphNode; 257 + if (node.x === undefined || node.y === undefined) return; 258 + 259 + const size = 260 + (node.val || NODE_SIZE.DEFAULT) * (1 / Math.sqrt(globalScale)); 261 + const isSelected = node === pinnedNode || node === hoverNode; 262 + 263 + // Calculate opacity for fade-in animation (800ms duration) 264 + let opacity = 1; 265 + if (node.__addedAt) { 266 + const age = Date.now() - node.__addedAt; 267 + const fadeDuration = 800; // ms 268 + if (age < fadeDuration) { 269 + // Ease-in opacity from 0 to 1 270 + opacity = Math.min(1, age / fadeDuration); 271 + // Ease-out cubic for smoother animation 272 + opacity = 1 - Math.pow(1 - opacity, 3); 273 + } 274 + } 275 + 276 + // Save context state for opacity 277 + ctx.save(); 278 + ctx.globalAlpha = opacity; 279 + 280 + // Create gradient for node fill 281 + const gradient = ctx.createRadialGradient( 282 + node.x, 283 + node.y, 284 + 0, 285 + node.x, 286 + node.y, 287 + size * 1.5, 288 + ); 289 + const primaryColor = getNodeColor(node.type); 290 + const secondaryColor = getNodeSecondaryColor(node.type); 291 + gradient.addColorStop(0, primaryColor); 292 + gradient.addColorStop( 293 + 1, 294 + isSelected ? VISUAL_CONFIG.node.shadowColor : secondaryColor, 295 + ); 296 + 297 + // Draw node circle 298 + ctx.beginPath(); 299 + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); 300 + ctx.fillStyle = gradient; 301 + ctx.fill(); 302 + 303 + // Add glow effect for selected nodes 304 + if (isSelected) { 305 + ctx.shadowColor = VISUAL_CONFIG.node.shadowColor; 306 + ctx.shadowBlur = VISUAL_CONFIG.node.shadowBlurSelected; 307 + } 308 + 309 + // Draw border 310 + ctx.strokeStyle = VISUAL_CONFIG.node.borderColor; 311 + ctx.lineWidth = isSelected 312 + ? VISUAL_CONFIG.node.borderWidthSelected 313 + : VISUAL_CONFIG.node.borderWidth; 314 + ctx.stroke(); 315 + 316 + // Reset shadow 317 + ctx.shadowBlur = VISUAL_CONFIG.node.shadowBlur; 318 + 319 + // Draw connection count badge for highly connected nodes 320 + if (node.connectionCount && node.connectionCount >= 5) { 321 + const badgeSize = 4 / globalScale; 322 + ctx.beginPath(); 323 + ctx.arc(node.x + size, node.y - size, badgeSize, 0, 2 * Math.PI); 324 + ctx.fillStyle = VISUAL_CONFIG.node.shadowColor; 325 + ctx.fill(); 326 + } 327 + 328 + // Restore context state (opacity) 329 + ctx.restore(); 330 + }, 331 + [pinnedNode, hoverNode], 332 + ); 333 + 334 + // Custom node pointer area for better hit detection 335 + const nodePointerAreaPaint = useCallback( 336 + (nodeData: any, color: string, ctx: CanvasRenderingContext2D) => { 337 + const node = nodeData as ExtendedGraphNode; 338 + if (node.x === undefined || node.y === undefined) return; 339 + 340 + const size = 341 + (node.val || NODE_SIZE.DEFAULT) * INTERACTION_CONFIG.hitAreaMultiplier; 342 + ctx.fillStyle = color; 343 + ctx.beginPath(); 344 + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); 345 + ctx.fill(); 346 + }, 347 + [], 348 + ); 349 + 350 + if (isLoading || !filteredGraphData) { 351 + return ( 352 + <Box pos="relative" h="100vh" w="100%"> 353 + <LoadingOverlay visible /> 354 + </Box> 355 + ); 356 + } 357 + 358 + return ( 359 + <Box pos="relative" h="calc(100vh - 60px)" w="100%" className={styles.root}> 360 + {/* Filter Panel with Depth Slider */} 361 + <GraphFilterPanel 362 + visibleNodeTypes={visibleNodeTypes} 363 + visibleEdgeTypes={visibleEdgeTypes} 364 + onNodeTypeToggle={handleNodeTypeToggle} 365 + onEdgeTypeToggle={handleEdgeTypeToggle} 366 + depth={depth} 367 + onDepthChange={handleDepthChange} 368 + showDepthControl={true} 369 + defaultCollapsed={false} 370 + /> 371 + 372 + <ForceGraph2D 373 + ref={graphRef} 374 + graphData={filteredGraphData} 375 + nodeCanvasObject={nodeCanvasObject} 376 + nodePointerAreaPaint={nodePointerAreaPaint} 377 + onNodeHover={handleNodeHover} 378 + onNodeClick={handleNodeClick} 379 + onBackgroundClick={handleBackgroundClick} 380 + onZoom={handleZoomPan} 381 + onEngineTick={handleZoomPan} 382 + backgroundColor={VISUAL_CONFIG.backgroundColor} 383 + linkColor={() => VISUAL_CONFIG.link.color} 384 + linkWidth={() => VISUAL_CONFIG.link.width} 385 + linkDirectionalArrowLength={VISUAL_CONFIG.arrow.length} 386 + linkDirectionalArrowRelPos={VISUAL_CONFIG.arrow.relativePosition} 387 + warmupTicks={PHYSICS_CONFIG.warmupTicks} 388 + cooldownTicks={PHYSICS_CONFIG.cooldownTicks} 389 + d3AlphaDecay={PHYSICS_CONFIG.d3AlphaDecay} 390 + d3VelocityDecay={PHYSICS_CONFIG.d3VelocityDecay} 391 + enableNodeDrag={INTERACTION_CONFIG.enableNodeDrag} 392 + enableZoomInteraction={INTERACTION_CONFIG.enableZoom} 393 + enablePanInteraction={INTERACTION_CONFIG.enablePan} 394 + minZoom={INTERACTION_CONFIG.minZoom} 395 + maxZoom={INTERACTION_CONFIG.maxZoom} 396 + /> 397 + 398 + {/* Hover preview popup */} 399 + {hoverNode && !pinnedNode && ( 400 + <NodePopupPreview node={hoverNode} position={previewPos} /> 401 + )} 402 + 403 + {/* Click detail popup */} 404 + {pinnedNode && ( 405 + <NodePopupDetail 406 + node={pinnedNode} 407 + position={detailPos} 408 + onClose={handleCloseDetail} 409 + onNavigate={handleNavigate} 410 + /> 411 + )} 412 + </Box> 413 + ); 414 + }
+472
src/webapp/features/graph/components/graphView/UserGraphView.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; 4 + import dynamic from 'next/dynamic'; 5 + import { Box, LoadingOverlay } from '@mantine/core'; 6 + import useUserGraphData from '../../lib/queries/useUserGraphData'; 7 + import type { 8 + ExtendedGraphNode, 9 + ExtendedGraphEdge, 10 + PopupPosition, 11 + } from '../../types'; 12 + import { 13 + getNodeColor, 14 + getNodeSecondaryColor, 15 + NODE_SIZE, 16 + } from '../../lib/utils/nodeStyles'; 17 + import { 18 + PHYSICS_CONFIG, 19 + VISUAL_CONFIG, 20 + INTERACTION_CONFIG, 21 + } from '../../lib/utils/graphConfig'; 22 + import NodePopupPreview from '../nodePopups/NodePopupPreview'; 23 + import NodePopupDetail from '../nodePopups/NodePopupDetail'; 24 + import GraphFilterPanel from './GraphFilterPanel'; 25 + import { useRouter } from 'next/navigation'; 26 + import styles from './GraphView.module.css'; 27 + 28 + // Dynamically import ForceGraph2D to avoid SSR issues 29 + const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { 30 + ssr: false, 31 + loading: () => ( 32 + <Box pos="relative" h="100vh" w="100%"> 33 + <LoadingOverlay visible /> 34 + </Box> 35 + ), 36 + }); 37 + 38 + // Type definitions for filters 39 + type NodeType = 'USER' | 'COLLECTION' | 'URL' | 'NOTE'; 40 + type EdgeType = 41 + | 'USER_FOLLOWS_USER' 42 + | 'USER_FOLLOWS_COLLECTION' 43 + | 'USER_AUTHORED_URL' 44 + | 'NOTE_REFERENCES_URL' 45 + | 'COLLECTION_CONTAINS_URL' 46 + | 'URL_CONNECTS_URL'; 47 + 48 + interface UserGraphViewProps { 49 + identifier: string; 50 + } 51 + 52 + export default function UserGraphView({ identifier }: UserGraphViewProps) { 53 + const router = useRouter(); 54 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 + const graphRef = useRef<any>(undefined); 56 + 57 + // State for dual popup system 58 + const [hoverNode, setHoverNode] = useState<ExtendedGraphNode | null>(null); 59 + const [pinnedNode, setPinnedNode] = useState<ExtendedGraphNode | null>(null); 60 + const [previewPos, setPreviewPos] = useState<PopupPosition>({ x: 0, y: 0 }); 61 + const [detailPos, setDetailPos] = useState<PopupPosition>({ x: 0, y: 0 }); 62 + 63 + // State for graph filters (USER hidden by default in user graph view) 64 + const [visibleNodeTypes, setVisibleNodeTypes] = useState<Set<NodeType>>( 65 + new Set(['COLLECTION', 'URL', 'NOTE'] as NodeType[]), 66 + ); 67 + const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<Set<EdgeType>>( 68 + new Set([ 69 + 'USER_FOLLOWS_USER', 70 + 'USER_FOLLOWS_COLLECTION', 71 + 'USER_AUTHORED_URL', 72 + 'NOTE_REFERENCES_URL', 73 + 'COLLECTION_CONTAINS_URL', 74 + 'URL_CONNECTS_URL', 75 + ] as EdgeType[]), 76 + ); 77 + 78 + // Fetch and process graph data for the specific user 79 + const { data: graphData } = useUserGraphData(identifier); 80 + 81 + // Filter graph data based on visible types 82 + const filteredGraphData = useMemo(() => { 83 + if (!graphData) return null; 84 + 85 + // Filter nodes by visible types 86 + const filteredNodes = graphData.nodes.filter((node) => 87 + visibleNodeTypes.has(node.type as NodeType), 88 + ); 89 + 90 + // Create a set of visible node IDs for efficient lookup 91 + const visibleNodeIds = new Set(filteredNodes.map((node) => node.id)); 92 + 93 + // Filter edges by visible edge types AND ensure both endpoints are visible 94 + const filteredLinks = graphData.links.filter((edge) => { 95 + // Check if edge type is visible 96 + if (!visibleEdgeTypes.has(edge.type as EdgeType)) return false; 97 + 98 + // Get source and target IDs (handles both string and object references) 99 + const sourceId = 100 + typeof edge.source === 'string' 101 + ? edge.source 102 + : (edge.source as ExtendedGraphNode).id; 103 + const targetId = 104 + typeof edge.target === 'string' 105 + ? edge.target 106 + : (edge.target as ExtendedGraphNode).id; 107 + 108 + // Only include edge if both nodes are visible 109 + return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId); 110 + }); 111 + 112 + return { 113 + nodes: filteredNodes, 114 + links: filteredLinks, 115 + }; 116 + }, [graphData, visibleNodeTypes, visibleEdgeTypes]); 117 + 118 + // Toggle handlers for filter panel 119 + const handleNodeTypeToggle = useCallback((type: NodeType) => { 120 + setVisibleNodeTypes((prev) => { 121 + const newSet = new Set(prev); 122 + if (newSet.has(type)) { 123 + newSet.delete(type); 124 + } else { 125 + newSet.add(type); 126 + } 127 + return newSet; 128 + }); 129 + }, []); 130 + 131 + const handleEdgeTypeToggle = useCallback((type: EdgeType) => { 132 + setVisibleEdgeTypes((prev) => { 133 + const newSet = new Set(prev); 134 + if (newSet.has(type)) { 135 + newSet.delete(type); 136 + } else { 137 + newSet.add(type); 138 + } 139 + return newSet; 140 + }); 141 + }, []); 142 + 143 + // Track previous node count to detect new nodes and trigger smooth transitions 144 + const prevNodeCountRef = useRef(0); 145 + const animationFrameRef = useRef<number | null>(null); 146 + 147 + // Detect when new nodes are added and gently reheat simulation 148 + useEffect(() => { 149 + if (!filteredGraphData || !graphRef.current) return; 150 + 151 + const currentNodeCount = filteredGraphData.nodes.length; 152 + const hasNewNodes = currentNodeCount > prevNodeCountRef.current; 153 + 154 + if (hasNewNodes && prevNodeCountRef.current > 0) { 155 + // Gently reheat the simulation for smooth repositioning 156 + // Lower alpha = gentler movement 157 + graphRef.current.d3ReheatSimulation(); 158 + graphRef.current.d3Force('charge')?.strength(-30); // Softer repulsion during transition 159 + 160 + // Restore normal force after animation completes 161 + setTimeout(() => { 162 + if (graphRef.current?.d3Force) { 163 + graphRef.current.d3Force('charge')?.strength(-60); 164 + } 165 + }, 1000); 166 + } 167 + 168 + prevNodeCountRef.current = currentNodeCount; 169 + }, [filteredGraphData]); 170 + 171 + // Animation loop to continuously re-render for fade-in effect 172 + useEffect(() => { 173 + if (!graphData) return; 174 + 175 + const animate = () => { 176 + // Check if any nodes are still fading in (added within last 800ms) 177 + const now = Date.now(); 178 + const hasFadingNodes = graphData.nodes.some( 179 + (node) => node.__addedAt && now - node.__addedAt < 800, 180 + ); 181 + 182 + if (hasFadingNodes && graphRef.current) { 183 + // Trigger a re-render by calling refresh 184 + graphRef.current._destructor?.(); // Force canvas redraw 185 + } 186 + 187 + if (hasFadingNodes) { 188 + animationFrameRef.current = requestAnimationFrame(animate); 189 + } else { 190 + animationFrameRef.current = null; 191 + } 192 + }; 193 + 194 + // Start animation if we have new nodes 195 + if (!animationFrameRef.current) { 196 + const now = Date.now(); 197 + const hasNewNodes = graphData.nodes.some( 198 + (node) => node.__addedAt && now - node.__addedAt < 800, 199 + ); 200 + if (hasNewNodes) { 201 + animationFrameRef.current = requestAnimationFrame(animate); 202 + } 203 + } 204 + 205 + return () => { 206 + if (animationFrameRef.current) { 207 + cancelAnimationFrame(animationFrameRef.current); 208 + } 209 + }; 210 + }, [graphData]); 211 + 212 + // Handle node hover (preview popup) 213 + const handleNodeHover = useCallback( 214 + (node: any) => { 215 + const typedNode = node as ExtendedGraphNode | null; 216 + if ( 217 + typedNode && 218 + !pinnedNode && 219 + typedNode.x !== undefined && 220 + typedNode.y !== undefined 221 + ) { 222 + setHoverNode(typedNode); 223 + // Calculate screen coordinates for the node 224 + if (graphRef.current) { 225 + const screenPos = graphRef.current.graph2ScreenCoords( 226 + typedNode.x, 227 + typedNode.y, 228 + ); 229 + setPreviewPos({ x: screenPos.x + 15, y: screenPos.y - 10 }); 230 + } 231 + } else if (!pinnedNode) { 232 + setHoverNode(null); 233 + } 234 + }, 235 + [pinnedNode], 236 + ); 237 + 238 + // Handle node click (detail popup) 239 + const handleNodeClick = useCallback((node: any) => { 240 + const typedNode = node as ExtendedGraphNode; 241 + setPinnedNode(typedNode); 242 + setHoverNode(null); // Hide preview when pinning detail 243 + 244 + // Convert graph coordinates to screen coordinates 245 + if ( 246 + graphRef.current && 247 + typedNode.x !== undefined && 248 + typedNode.y !== undefined 249 + ) { 250 + const screenPos = graphRef.current.graph2ScreenCoords( 251 + typedNode.x, 252 + typedNode.y, 253 + ); 254 + // Offset to the right of the node 255 + setDetailPos({ x: screenPos.x + 20, y: screenPos.y }); 256 + } 257 + }, []); 258 + 259 + // Handle background click (close detail popup) 260 + const handleBackgroundClick = useCallback(() => { 261 + setPinnedNode(null); 262 + }, []); 263 + 264 + // Update detail popup position during zoom/pan 265 + const handleZoomPan = useCallback(() => { 266 + if ( 267 + pinnedNode && 268 + graphRef.current && 269 + pinnedNode.x !== undefined && 270 + pinnedNode.y !== undefined 271 + ) { 272 + const screenPos = graphRef.current.graph2ScreenCoords( 273 + pinnedNode.x, 274 + pinnedNode.y, 275 + ); 276 + setDetailPos({ x: screenPos.x + 20, y: screenPos.y }); 277 + } 278 + }, [pinnedNode]); 279 + 280 + // Handle navigation from popups 281 + const handleNavigate = useCallback( 282 + (nodeId: string) => { 283 + const node = filteredGraphData?.nodes.find((n) => n.id === nodeId); 284 + if (!node) return; 285 + 286 + let route: string; 287 + switch (node.type) { 288 + case 'USER': 289 + route = `/profile/${node.metadata.handle}`; 290 + break; 291 + case 'COLLECTION': 292 + route = `/collections/${node.metadata.handle}/${node.metadata.rkey}`; 293 + break; 294 + case 'URL': 295 + route = `/url?id=${encodeURIComponent(node.metadata.url)}`; 296 + break; 297 + case 'NOTE': 298 + route = `/url?id=${encodeURIComponent(node.metadata.parentUrl)}`; 299 + break; 300 + default: 301 + return; 302 + } 303 + 304 + router.push(route); 305 + }, 306 + [filteredGraphData, router], 307 + ); 308 + 309 + // Close detail popup 310 + const handleCloseDetail = useCallback(() => { 311 + setPinnedNode(null); 312 + }, []); 313 + 314 + // Custom node canvas renderer 315 + const nodeCanvasObject = useCallback( 316 + (nodeData: any, ctx: CanvasRenderingContext2D, globalScale: number) => { 317 + const node = nodeData as ExtendedGraphNode; 318 + if (node.x === undefined || node.y === undefined) return; 319 + 320 + const size = 321 + (node.val || NODE_SIZE.DEFAULT) * (1 / Math.sqrt(globalScale)); 322 + const isSelected = node === pinnedNode || node === hoverNode; 323 + 324 + // Calculate opacity for fade-in animation (800ms duration) 325 + let opacity = 1; 326 + if (node.__addedAt) { 327 + const age = Date.now() - node.__addedAt; 328 + const fadeDuration = 800; // ms 329 + if (age < fadeDuration) { 330 + // Ease-in opacity from 0 to 1 331 + opacity = Math.min(1, age / fadeDuration); 332 + // Ease-out cubic for smoother animation 333 + opacity = 1 - Math.pow(1 - opacity, 3); 334 + } 335 + } 336 + 337 + // Save context state for opacity 338 + ctx.save(); 339 + ctx.globalAlpha = opacity; 340 + 341 + // Create gradient for node fill 342 + const gradient = ctx.createRadialGradient( 343 + node.x, 344 + node.y, 345 + 0, 346 + node.x, 347 + node.y, 348 + size * 1.5, 349 + ); 350 + const primaryColor = getNodeColor(node.type); 351 + const secondaryColor = getNodeSecondaryColor(node.type); 352 + gradient.addColorStop(0, primaryColor); 353 + gradient.addColorStop( 354 + 1, 355 + isSelected ? VISUAL_CONFIG.node.shadowColor : secondaryColor, 356 + ); 357 + 358 + // Draw node circle 359 + ctx.beginPath(); 360 + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); 361 + ctx.fillStyle = gradient; 362 + ctx.fill(); 363 + 364 + // Add glow effect for selected nodes 365 + if (isSelected) { 366 + ctx.shadowColor = VISUAL_CONFIG.node.shadowColor; 367 + ctx.shadowBlur = VISUAL_CONFIG.node.shadowBlurSelected; 368 + } 369 + 370 + // Draw border 371 + ctx.strokeStyle = VISUAL_CONFIG.node.borderColor; 372 + ctx.lineWidth = isSelected 373 + ? VISUAL_CONFIG.node.borderWidthSelected 374 + : VISUAL_CONFIG.node.borderWidth; 375 + ctx.stroke(); 376 + 377 + // Reset shadow 378 + ctx.shadowBlur = VISUAL_CONFIG.node.shadowBlur; 379 + 380 + // Draw connection count badge for highly connected nodes 381 + if (node.connectionCount && node.connectionCount >= 5) { 382 + const badgeSize = 4 / globalScale; 383 + ctx.beginPath(); 384 + ctx.arc(node.x + size, node.y - size, badgeSize, 0, 2 * Math.PI); 385 + ctx.fillStyle = VISUAL_CONFIG.node.shadowColor; 386 + ctx.fill(); 387 + } 388 + 389 + // Restore context state (opacity) 390 + ctx.restore(); 391 + }, 392 + [pinnedNode, hoverNode], 393 + ); 394 + 395 + // Custom node pointer area for better hit detection 396 + const nodePointerAreaPaint = useCallback( 397 + (nodeData: any, color: string, ctx: CanvasRenderingContext2D) => { 398 + const node = nodeData as ExtendedGraphNode; 399 + if (node.x === undefined || node.y === undefined) return; 400 + 401 + const size = 402 + (node.val || NODE_SIZE.DEFAULT) * INTERACTION_CONFIG.hitAreaMultiplier; 403 + ctx.fillStyle = color; 404 + ctx.beginPath(); 405 + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); 406 + ctx.fill(); 407 + }, 408 + [], 409 + ); 410 + 411 + if (!filteredGraphData) { 412 + return ( 413 + <Box pos="relative" h="100vh" w="100%"> 414 + <LoadingOverlay visible /> 415 + </Box> 416 + ); 417 + } 418 + 419 + return ( 420 + <Box pos="relative" h="calc(100vh - 60px)" w="100%" className={styles.root}> 421 + {/* Filter Panel */} 422 + <GraphFilterPanel 423 + visibleNodeTypes={visibleNodeTypes} 424 + visibleEdgeTypes={visibleEdgeTypes} 425 + onNodeTypeToggle={handleNodeTypeToggle} 426 + onEdgeTypeToggle={handleEdgeTypeToggle} 427 + hiddenNodeTypeControls={new Set(['USER'] as NodeType[])} 428 + /> 429 + 430 + <ForceGraph2D 431 + ref={graphRef} 432 + graphData={filteredGraphData} 433 + nodeCanvasObject={nodeCanvasObject} 434 + nodePointerAreaPaint={nodePointerAreaPaint} 435 + onNodeHover={handleNodeHover} 436 + onNodeClick={handleNodeClick} 437 + onBackgroundClick={handleBackgroundClick} 438 + onZoom={handleZoomPan} 439 + onEngineTick={handleZoomPan} 440 + backgroundColor={VISUAL_CONFIG.backgroundColor} 441 + linkColor={() => VISUAL_CONFIG.link.color} 442 + linkWidth={() => VISUAL_CONFIG.link.width} 443 + linkDirectionalArrowLength={VISUAL_CONFIG.arrow.length} 444 + linkDirectionalArrowRelPos={VISUAL_CONFIG.arrow.relativePosition} 445 + warmupTicks={PHYSICS_CONFIG.warmupTicks} 446 + cooldownTicks={PHYSICS_CONFIG.cooldownTicks} 447 + d3AlphaDecay={PHYSICS_CONFIG.d3AlphaDecay} 448 + d3VelocityDecay={PHYSICS_CONFIG.d3VelocityDecay} 449 + enableNodeDrag={INTERACTION_CONFIG.enableNodeDrag} 450 + enableZoomInteraction={INTERACTION_CONFIG.enableZoom} 451 + enablePanInteraction={INTERACTION_CONFIG.enablePan} 452 + minZoom={INTERACTION_CONFIG.minZoom} 453 + maxZoom={INTERACTION_CONFIG.maxZoom} 454 + /> 455 + 456 + {/* Hover preview popup */} 457 + {hoverNode && !pinnedNode && ( 458 + <NodePopupPreview node={hoverNode} position={previewPos} /> 459 + )} 460 + 461 + {/* Click detail popup */} 462 + {pinnedNode && ( 463 + <NodePopupDetail 464 + node={pinnedNode} 465 + position={detailPos} 466 + onClose={handleCloseDetail} 467 + onNavigate={handleNavigate} 468 + /> 469 + )} 470 + </Box> 471 + ); 472 + }
+36 -2
src/webapp/features/graph/lib/dal.ts
··· 3 3 import { verifySessionOnClient } from '@/lib/auth/dal'; 4 4 5 5 /** 6 - * Fetch graph data from the backend 6 + * Fetch a specific page of graph data from the backend 7 + * Returns nodes and edges for the specified page with pagination metadata 8 + * 9 + * @param page - Page number (1-indexed, defaults to 1) 10 + * @param limit - Number of nodes per page (defaults to 300) 11 + */ 12 + export const getGraphDataPage = cache( 13 + async (page: number = 1, limit: number = 300) => { 14 + // Verify authentication - graph data is personalized 15 + const session = await verifySessionOnClient({ redirectOnFail: true }); 16 + if (!session) throw new Error('No session found'); 17 + 18 + const client = createSembleClient(); 19 + const response = await client.getGraphData({ page, limit }); 20 + 21 + return response; 22 + }, 23 + ); 24 + 25 + /** 26 + * Fetch all graph data from the backend (deprecated - use getGraphDataPage for better performance) 7 27 * Returns all nodes and edges for the current user's knowledge graph 28 + * 29 + * @deprecated Use getGraphDataPage instead for incremental loading 8 30 */ 9 31 export const getGraphData = cache(async () => { 32 + // For backward compatibility, fetch page 1 with a large limit 33 + return getGraphDataPage(1, 10000); 34 + }); 35 + 36 + /** 37 + * Fetch URL-centric sub-graph with depth-based traversal 38 + * Returns all nodes and edges within N hops of the target URL 39 + * 40 + * @param url - Target URL to center the sub-graph around 41 + * @param depth - Number of edge hops to traverse (1-5, defaults to 1) 42 + */ 43 + export const getUrlGraphData = cache(async (url: string, depth: number = 1) => { 10 44 // Verify authentication - graph data is personalized 11 45 const session = await verifySessionOnClient({ redirectOnFail: true }); 12 46 if (!session) throw new Error('No session found'); 13 47 14 48 const client = createSembleClient(); 15 - const response = await client.getGraphData(); 49 + const response = await client.getUrlGraphData({ url, depth }); 16 50 17 51 return response; 18 52 });
+7
src/webapp/features/graph/lib/graphKeys.ts
··· 5 5 export const graphKeys = { 6 6 all: () => ['graph'] as const, 7 7 data: () => [...graphKeys.all(), 'data'] as const, 8 + page: (page: number) => [...graphKeys.all(), 'page', page] as const, 9 + user: (identifier: string) => 10 + [...graphKeys.all(), 'user', identifier] as const, 11 + userPage: (identifier: string, page: number) => 12 + [...graphKeys.all(), 'user', identifier, 'page', page] as const, 13 + url: (url: string, depth: number) => 14 + [...graphKeys.all(), 'url', url, 'depth', depth] as const, 8 15 node: (nodeId: string) => [...graphKeys.all(), 'node', nodeId] as const, 9 16 } as const;
+157 -55
src/webapp/features/graph/lib/queries/useGraphData.tsx
··· 1 - import { useSuspenseQuery } from '@tanstack/react-query'; 2 - import { useMemo } from 'react'; 1 + import { useQueries } from '@tanstack/react-query'; 2 + import { useState, useEffect, useRef } from 'react'; 3 3 import { graphKeys } from '../graphKeys'; 4 - import { getGraphData } from '../dal'; 4 + import { getGraphDataPage } from '../dal'; 5 5 import type { 6 6 ProcessedGraphData, 7 7 ExtendedGraphNode, 8 8 ExtendedGraphEdge, 9 9 } from '../../types'; 10 10 import { calculateNodeSize, getNodeColor } from '../utils/nodeStyles'; 11 + import type { GetGraphDataResponse } from '@semble/types'; 11 12 12 13 /** 13 - * Hook to fetch and process graph data 14 - * Automatically calculates connection counts, node sizes, and colors 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 15 17 */ 16 18 export default function useGraphData() { 17 - // Fetch raw data from backend 18 - const query = useSuspenseQuery({ 19 - queryKey: graphKeys.data(), 20 - queryFn: getGraphData, 21 - staleTime: 5 * 60 * 1000, // 5 minutes (graph data doesn't change frequently) 22 - refetchOnWindowFocus: false, // Don't refetch on focus (expensive operation) 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: [], 23 26 }); 24 27 25 - // Process the data to add visual properties 26 - const processedData: ProcessedGraphData | undefined = useMemo(() => { 27 - if (!query.data) return undefined; 28 + // Track which pages we've already processed to avoid reprocessing 29 + const processedPagesRef = useRef<Set<number>>(new Set()); 28 30 29 - // Step 1: Calculate connection counts 30 - const connectionCounts: Record<string, number> = {}; 31 - query.data.edges.forEach((edge) => { 32 - const sourceId = 33 - typeof edge.source === 'string' ? edge.source : edge.source; 34 - const targetId = 35 - typeof edge.target === 'string' ? edge.target : edge.target; 31 + // Maintain stable node references (important for smooth graph transitions) 32 + const nodeMapRef = useRef<Map<string, ExtendedGraphNode>>(new Map()); 36 33 37 - connectionCounts[sourceId] = (connectionCounts[sourceId] || 0) + 1; 38 - connectionCounts[targetId] = (connectionCounts[targetId] || 0) + 1; 39 - }); 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 + refetchOnWindowFocus: false, 41 + })), 42 + }); 40 43 41 - // Step 2: Process nodes with visual properties 42 - const processedNodes: ExtendedGraphNode[] = query.data.nodes.map((node) => { 43 - const connectionCount = connectionCounts[node.id] || 0; 44 - const nodeSize = calculateNodeSize(connectionCount); 45 - const nodeColor = getNodeColor(node.type); 44 + // Incrementally merge new pages as they complete 45 + useEffect(() => { 46 + const successfulQueries = queries.filter((q) => q.isSuccess && q.data); 47 + if (successfulQueries.length === 0) return; 46 48 47 - return { 48 - ...node, 49 - connectionCount, 50 - val: nodeSize, 51 - color: nodeColor, 52 - }; 49 + // Find pages we haven't processed yet 50 + const newPages: { page: number; data: GetGraphDataResponse }[] = []; 51 + successfulQueries.forEach((q, idx) => { 52 + const page = pagesToLoad[idx]; 53 + if (!processedPagesRef.current.has(page)) { 54 + newPages.push({ page, data: q.data as GetGraphDataResponse }); 55 + } 53 56 }); 54 57 55 - // Create a set of valid node IDs for edge validation 56 - const validNodeIds = new Set(processedNodes.map((node) => node.id)); 58 + // Update pagination metadata and queue next page 59 + const latestData = successfulQueries[successfulQueries.length - 1] 60 + .data as GetGraphDataResponse; 61 + 62 + if (latestData?.pagination) { 63 + setTotalPages(latestData.pagination.totalPages); 64 + 65 + // Queue next page if available 66 + if ( 67 + latestData.pagination.hasMore && 68 + !pagesToLoad.includes(latestData.pagination.currentPage + 1) 69 + ) { 70 + setPagesToLoad((prev) => [ 71 + ...prev, 72 + latestData.pagination.currentPage + 1, 73 + ]); 74 + } 75 + } 57 76 58 - // Step 3: Process edges (add value for thickness if needed) 59 - // Filter out edges that reference non-existent nodes 60 - const processedEdges: ExtendedGraphEdge[] = query.data.edges 61 - .filter((edge) => { 77 + // If no new pages, nothing to merge 78 + if (newPages.length === 0) return; 79 + 80 + // Mark pages as processed 81 + newPages.forEach(({ page }) => processedPagesRef.current.add(page)); 82 + 83 + // Incrementally merge new data 84 + setProcessedData((prevData) => { 85 + // Extract new nodes and edges from newly loaded pages 86 + const newNodes = newPages.flatMap(({ data }) => data.nodes); 87 + const newEdges = newPages.flatMap(({ data }) => data.edges); 88 + 89 + // Add new nodes to map (preserving existing node references) 90 + newNodes.forEach((node) => { 91 + if (!nodeMapRef.current.has(node.id)) { 92 + // Create new extended node with timestamp for fade-in animation 93 + const extendedNode: ExtendedGraphNode = { 94 + ...node, 95 + connectionCount: 0, 96 + val: 0, 97 + color: getNodeColor(node.type), 98 + // Track when node was added for smooth fade-in 99 + __addedAt: Date.now(), 100 + }; 101 + nodeMapRef.current.set(node.id, extendedNode); 102 + } 103 + }); 104 + 105 + // Combine all edges (previous + new) 106 + const allEdges = [...prevData.links, ...newEdges]; 107 + 108 + // Recalculate connection counts for ALL nodes (new edges affect existing nodes) 109 + const connectionCounts: Record<string, number> = {}; 110 + allEdges.forEach((edge) => { 111 + // Handle both string IDs and object references (ForceGraph2D mutates edges) 62 112 const sourceId = 63 - typeof edge.source === 'string' ? edge.source : edge.source; 113 + typeof edge.source === 'string' 114 + ? edge.source 115 + : (edge.source as ExtendedGraphNode).id; 64 116 const targetId = 65 - typeof edge.target === 'string' ? edge.target : edge.target; 66 - return validNodeIds.has(sourceId) && validNodeIds.has(targetId); 67 - }) 68 - .map((edge) => ({ 69 - ...edge, 70 - value: 1, // Default thickness, can be customized based on edge type 71 - })); 117 + typeof edge.target === 'string' 118 + ? edge.target 119 + : (edge.target as ExtendedGraphNode).id; 72 120 73 - return { 74 - nodes: processedNodes, 75 - links: processedEdges, 76 - }; 77 - }, [query.data]); 121 + connectionCounts[sourceId] = (connectionCounts[sourceId] || 0) + 1; 122 + connectionCounts[targetId] = (connectionCounts[targetId] || 0) + 1; 123 + }); 124 + 125 + // Update connection counts on existing node objects (mutate in place) 126 + nodeMapRef.current.forEach((node) => { 127 + const connectionCount = connectionCounts[node.id] || 0; 128 + node.connectionCount = connectionCount; 129 + node.val = calculateNodeSize(connectionCount); 130 + // color stays the same 131 + }); 132 + 133 + // Get all nodes as array (stable references for existing nodes) 134 + const allNodes = Array.from(nodeMapRef.current.values()); 135 + 136 + // Create a set of valid node IDs for edge validation 137 + const validNodeIds = new Set(allNodes.map((n) => n.id)); 138 + 139 + // Process edges (add value for thickness if needed) 140 + // Filter out edges that reference non-existent nodes 141 + const processedEdges: ExtendedGraphEdge[] = allEdges 142 + .filter((edge) => { 143 + const sourceId = 144 + typeof edge.source === 'string' 145 + ? edge.source 146 + : (edge.source as ExtendedGraphNode).id; 147 + const targetId = 148 + typeof edge.target === 'string' 149 + ? edge.target 150 + : (edge.target as ExtendedGraphNode).id; 151 + return validNodeIds.has(sourceId) && validNodeIds.has(targetId); 152 + }) 153 + .map((edge) => ({ 154 + ...edge, 155 + value: 1, // Default thickness, can be customized based on edge type 156 + })); 157 + 158 + return { 159 + nodes: allNodes, 160 + links: processedEdges, 161 + }; 162 + }); 163 + }, [queries, pagesToLoad]); 164 + 165 + // Aggregate query state 166 + const isLoading = queries.some((q) => q.isLoading); 167 + const isError = queries.some((q) => q.isError); 168 + const error = queries.find((q) => q.error)?.error; 169 + 170 + // Calculate overall loading progress 171 + const loadedPages = queries.filter((q) => q.isSuccess).length; 172 + const loadingProgress = 173 + totalPages && totalPages > 0 ? (loadedPages / totalPages) * 100 : 0; 78 174 79 175 return { 80 - ...query, 81 176 data: processedData, 177 + isLoading, 178 + isError, 179 + error, 180 + loadingProgress, 181 + loadedPages, 182 + totalPages, 183 + isComplete: totalPages !== null && loadedPages >= totalPages, 82 184 }; 83 185 }
+82
src/webapp/features/graph/lib/queries/useUrlGraphData.tsx
··· 1 + import { useQuery } from '@tanstack/react-query'; 2 + import { useMemo } from 'react'; 3 + import { graphKeys } from '../graphKeys'; 4 + import { apiClient } from '@/api-client/ApiClient'; 5 + import type { 6 + ProcessedGraphData, 7 + ExtendedGraphNode, 8 + ExtendedGraphEdge, 9 + } from '../../types'; 10 + import { calculateNodeSize, getNodeColor } from '../utils/nodeStyles'; 11 + 12 + /** 13 + * Hook to fetch and process URL-centric sub-graph data with depth-based traversal 14 + * Fetches all nodes and edges within N hops of the target URL 15 + * Calculates connection counts, node sizes, and colors for all nodes 16 + */ 17 + export default function useUrlGraphData(url: string, depth: number = 1) { 18 + // Fetch URL sub-graph data 19 + const query = useQuery({ 20 + queryKey: graphKeys.url(url, depth), 21 + queryFn: () => apiClient.getUrlGraphData({ url, depth }), 22 + staleTime: 5 * 60 * 1000, // 5 minutes 23 + refetchOnWindowFocus: false, 24 + enabled: !!url, // Only fetch if URL is provided 25 + }); 26 + 27 + // Process the graph data 28 + const processedData = useMemo((): ProcessedGraphData => { 29 + if (!query.data) { 30 + return { nodes: [], links: [] }; 31 + } 32 + 33 + const { nodes, edges } = query.data; 34 + 35 + // Calculate connection counts for all nodes 36 + const connectionCounts: Record<string, number> = {}; 37 + edges.forEach((edge) => { 38 + connectionCounts[edge.source] = (connectionCounts[edge.source] || 0) + 1; 39 + connectionCounts[edge.target] = (connectionCounts[edge.target] || 0) + 1; 40 + }); 41 + 42 + // Process nodes: add connection count, size, and color 43 + const processedNodes: ExtendedGraphNode[] = nodes.map((node) => { 44 + const connectionCount = connectionCounts[node.id] || 0; 45 + 46 + return { 47 + ...node, 48 + connectionCount, 49 + val: calculateNodeSize(connectionCount), 50 + color: getNodeColor(node.type), 51 + // Track when node was added for smooth fade-in animation 52 + __addedAt: Date.now(), 53 + }; 54 + }); 55 + 56 + // Create a set of valid node IDs for edge validation 57 + const validNodeIds = new Set(processedNodes.map((n) => n.id)); 58 + 59 + // Process edges: filter out invalid edges and add visual properties 60 + const processedEdges: ExtendedGraphEdge[] = edges 61 + .filter((edge) => { 62 + return validNodeIds.has(edge.source) && validNodeIds.has(edge.target); 63 + }) 64 + .map((edge) => ({ 65 + ...edge, 66 + value: 1, // Default thickness 67 + })); 68 + 69 + return { 70 + nodes: processedNodes, 71 + links: processedEdges, 72 + }; 73 + }, [query.data]); 74 + 75 + return { 76 + data: processedData, 77 + isLoading: query.isLoading, 78 + isError: query.isError, 79 + error: query.error, 80 + isSuccess: query.isSuccess, 81 + }; 82 + }
+185
src/webapp/features/graph/lib/queries/useUserGraphData.tsx
··· 1 + import { useQueries } from '@tanstack/react-query'; 2 + import { useState, useEffect, useRef } from 'react'; 3 + import { graphKeys } from '../graphKeys'; 4 + import { apiClient } from '@/api-client/ApiClient'; 5 + import type { 6 + ProcessedGraphData, 7 + ExtendedGraphNode, 8 + ExtendedGraphEdge, 9 + } from '../../types'; 10 + import { calculateNodeSize, getNodeColor } from '../utils/nodeStyles'; 11 + import 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 + */ 18 + export 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 + refetchOnWindowFocus: false, 41 + })), 42 + }); 43 + 44 + // Incrementally merge new pages as they complete 45 + useEffect(() => { 46 + const successfulQueries = queries.filter((q) => q.isSuccess && q.data); 47 + if (successfulQueries.length === 0) return; 48 + 49 + // Find pages we haven't processed yet 50 + const newPages: { page: number; data: GetGraphDataResponse }[] = []; 51 + successfulQueries.forEach((q, idx) => { 52 + const page = pagesToLoad[idx]; 53 + if (!processedPagesRef.current.has(page)) { 54 + newPages.push({ page, data: q.data as GetGraphDataResponse }); 55 + } 56 + }); 57 + 58 + // Update pagination metadata and queue next page 59 + const latestData = successfulQueries[successfulQueries.length - 1] 60 + .data as GetGraphDataResponse; 61 + 62 + if (latestData?.pagination) { 63 + setTotalPages(latestData.pagination.totalPages); 64 + 65 + // Queue next page if available 66 + if ( 67 + latestData.pagination.hasMore && 68 + !pagesToLoad.includes(latestData.pagination.currentPage + 1) 69 + ) { 70 + setPagesToLoad((prev) => [ 71 + ...prev, 72 + latestData.pagination.currentPage + 1, 73 + ]); 74 + } 75 + } 76 + 77 + // If no new pages, nothing to merge 78 + if (newPages.length === 0) return; 79 + 80 + // Mark pages as processed 81 + newPages.forEach(({ page }) => processedPagesRef.current.add(page)); 82 + 83 + // Incrementally merge new data 84 + setProcessedData((prevData) => { 85 + // Extract new nodes and edges from newly loaded pages 86 + const newNodes = newPages.flatMap(({ data }) => data.nodes); 87 + const newEdges = newPages.flatMap(({ data }) => data.edges); 88 + 89 + // Add new nodes to map (preserving existing node references) 90 + newNodes.forEach((node) => { 91 + if (!nodeMapRef.current.has(node.id)) { 92 + // Create new extended node with timestamp for fade-in animation 93 + const extendedNode: ExtendedGraphNode = { 94 + ...node, 95 + connectionCount: 0, 96 + val: 0, 97 + color: getNodeColor(node.type), 98 + // Track when node was added for smooth fade-in 99 + __addedAt: Date.now(), 100 + }; 101 + nodeMapRef.current.set(node.id, extendedNode); 102 + } 103 + }); 104 + 105 + // Combine all edges (previous + new) 106 + const allEdges = [...prevData.links, ...newEdges]; 107 + 108 + // Recalculate connection counts for ALL nodes (new edges affect existing nodes) 109 + const connectionCounts: Record<string, number> = {}; 110 + allEdges.forEach((edge) => { 111 + // Handle both string IDs and object references (ForceGraph2D mutates edges) 112 + const sourceId = 113 + typeof edge.source === 'string' 114 + ? edge.source 115 + : (edge.source as ExtendedGraphNode).id; 116 + const targetId = 117 + typeof edge.target === 'string' 118 + ? edge.target 119 + : (edge.target as ExtendedGraphNode).id; 120 + 121 + connectionCounts[sourceId] = (connectionCounts[sourceId] || 0) + 1; 122 + connectionCounts[targetId] = (connectionCounts[targetId] || 0) + 1; 123 + }); 124 + 125 + // Update connection counts on existing node objects (mutate in place) 126 + nodeMapRef.current.forEach((node) => { 127 + const connectionCount = connectionCounts[node.id] || 0; 128 + node.connectionCount = connectionCount; 129 + node.val = calculateNodeSize(connectionCount); 130 + // color stays the same 131 + }); 132 + 133 + // Get all nodes as array (stable references for existing nodes) 134 + const allNodes = Array.from(nodeMapRef.current.values()); 135 + 136 + // Create a set of valid node IDs for edge validation 137 + const validNodeIds = new Set(allNodes.map((n) => n.id)); 138 + 139 + // Process edges (add value for thickness if needed) 140 + // Filter out edges that reference non-existent nodes 141 + const processedEdges: ExtendedGraphEdge[] = allEdges 142 + .filter((edge) => { 143 + const sourceId = 144 + typeof edge.source === 'string' 145 + ? edge.source 146 + : (edge.source as ExtendedGraphNode).id; 147 + const targetId = 148 + typeof edge.target === 'string' 149 + ? edge.target 150 + : (edge.target as ExtendedGraphNode).id; 151 + return validNodeIds.has(sourceId) && validNodeIds.has(targetId); 152 + }) 153 + .map((edge) => ({ 154 + ...edge, 155 + value: 1, // Default thickness, can be customized based on edge type 156 + })); 157 + 158 + return { 159 + nodes: allNodes, 160 + links: processedEdges, 161 + }; 162 + }); 163 + }, [queries, pagesToLoad]); 164 + 165 + // Aggregate query state 166 + const isLoading = queries.some((q) => q.isLoading); 167 + const isError = queries.some((q) => q.isError); 168 + const error = queries.find((q) => q.error)?.error; 169 + 170 + // Calculate overall loading progress 171 + const loadedPages = queries.filter((q) => q.isSuccess).length; 172 + const loadingProgress = 173 + totalPages && totalPages > 0 ? (loadedPages / totalPages) * 100 : 0; 174 + 175 + return { 176 + data: processedData, 177 + isLoading, 178 + isError, 179 + error, 180 + loadingProgress, 181 + loadedPages, 182 + totalPages, 183 + isComplete: totalPages !== null && loadedPages >= totalPages, 184 + }; 185 + }
+3
src/webapp/features/graph/types/index.ts
··· 22 22 vy?: number; 23 23 fx?: number; 24 24 fy?: number; 25 + 26 + // Animation properties 27 + __addedAt?: number; // Timestamp when node was added (for fade-in animation) 25 28 } 26 29 27 30 /**
+16 -1
src/webapp/features/semble/components/sembleTabs/SembleTabs.tsx
··· 29 29 import SembleConnectionsContainer from '../../containers/sembleConnectionsContainer/SembleConnectionsContainer'; 30 30 import SembleConnectionsContainerSkeleton from '../../containers/sembleConnectionsContainer/Skeleton.SembleConnectionsContainer'; 31 31 32 + import UrlGraphView from '@/features/graph/components/graphView/UrlGraphView'; 33 + 32 34 interface Props { 33 35 url: string; 34 36 } 35 37 36 - type TabValue = 'notes' | 'collections' | 'addedBy' | 'similar' | 'connections'; 38 + type TabValue = 39 + | 'notes' 40 + | 'collections' 41 + | 'addedBy' 42 + | 'similar' 43 + | 'connections' 44 + | 'graph'; 37 45 38 46 export default function SembleTabs(props: Props) { 39 47 const [activeTab, setActiveTab] = useState<TabValue>('similar'); ··· 59 67 Connections 60 68 </TabItem> 61 69 )} 70 + {featureFlags?.graphView && <TabItem value="graph">Graph</TabItem>} 62 71 <TabItem value="notes" count={stats?.noteCount}> 63 72 Notes 64 73 </TabItem> ··· 114 123 > 115 124 <SembleConnectionsContainer url={props.url} /> 116 125 </Suspense> 126 + </TabsPanel> 127 + )} 128 + 129 + {featureFlags?.graphView && ( 130 + <TabsPanel value="graph"> 131 + <UrlGraphView url={props.url} /> 117 132 </TabsPanel> 118 133 )} 119 134