diff --git a/src/modules/cards/application/useCases/queries/GetGraphDataUseCase.ts b/src/modules/cards/application/useCases/queries/GetGraphDataUseCase.ts index 1b747640..b9c11e87 100644 --- a/src/modules/cards/application/useCases/queries/GetGraphDataUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetGraphDataUseCase.ts @@ -1,10 +1,13 @@ import { Result, ok, err } from '../../../../../shared/core/Result'; import { UseCase } from '../../../../../shared/core/UseCase'; import { IGraphQueryRepository } from '../../../domain/IGraphQueryRepository'; +import { IIdentityResolutionService } from '../../../../atproto/domain/services/IIdentityResolutionService'; +import { DIDOrHandle } from '../../../../atproto/domain/DIDOrHandle'; export interface GetGraphDataQuery { page?: number; limit?: number; + identifier?: string; // Can be DID or handle } export interface GraphNode { @@ -37,14 +40,41 @@ export interface GetGraphDataResult { export class GetGraphDataUseCase implements UseCase> { - constructor(private graphQueryRepo: IGraphQueryRepository) {} + constructor( + private graphQueryRepo: IGraphQueryRepository, + private identityResolver: IIdentityResolutionService, + ) {} async execute(query: GetGraphDataQuery): Promise> { try { - // Fetch graph data with pagination + let userId: string | undefined = undefined; + + // If identifier is provided, resolve it to a DID + if (query.identifier) { + const identifierResult = DIDOrHandle.create(query.identifier); + if (identifierResult.isErr()) { + return err(new Error(`Invalid identifier: ${query.identifier}`)); + } + + const didResult = await this.identityResolver.resolveToDID( + identifierResult.value, + ); + if (didResult.isErr()) { + return err( + new Error( + `Failed to resolve identifier: ${didResult.error.message}`, + ), + ); + } + + userId = didResult.value.value; + } + + // Fetch graph data with pagination and optional user scoping const graphData = await this.graphQueryRepo.getGraphData( query.page, query.limit, + userId, ); return ok({ diff --git a/src/modules/cards/domain/IGraphQueryRepository.ts b/src/modules/cards/domain/IGraphQueryRepository.ts index 9f0b732e..19efa745 100644 --- a/src/modules/cards/domain/IGraphQueryRepository.ts +++ b/src/modules/cards/domain/IGraphQueryRepository.ts @@ -28,11 +28,16 @@ export interface GraphDataDTO { export interface IGraphQueryRepository { /** - * Get nodes and edges for the global graph visualization with pagination support + * Get nodes and edges for graph visualization with pagination support * Returns paginated graph data with total count for calculating pagination metadata * * @param page - Page number (1-indexed, defaults to 1) * @param limit - Number of nodes per page (defaults to 300) + * @param userId - Optional user DID to scope the graph to a specific user's data */ - getGraphData(page?: number, limit?: number): Promise; + getGraphData( + page?: number, + limit?: number, + userId?: string, + ): Promise; } diff --git a/src/modules/cards/infrastructure/http/controllers/GetUserGraphDataController.ts b/src/modules/cards/infrastructure/http/controllers/GetUserGraphDataController.ts new file mode 100644 index 00000000..923bbe19 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/GetUserGraphDataController.ts @@ -0,0 +1,58 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { GetGraphDataUseCase } from '../../../application/useCases/queries/GetGraphDataUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export class GetUserGraphDataController extends Controller { + constructor(private getGraphDataUseCase: GetGraphDataUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + // Extract identifier from route params + const identifier = req.params.identifier; + if (!identifier) { + return this.badRequest(res, 'Identifier is required'); + } + + // Parse pagination parameters from query string + const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; + const limit = req.query.limit + ? parseInt(req.query.limit as string, 10) + : 300; + + const result = await this.getGraphDataUseCase.execute({ + page, + limit, + identifier, + }); + + if (result.isErr()) { + return this.fail(res, result.error); + } + + // Calculate pagination metadata + const totalCount = result.value.totalNodeCount; + const totalPages = Math.ceil(totalCount / limit); + const hasMore = page < totalPages; + + // Build response with pagination + const response = { + nodes: result.value.nodes, + edges: result.value.edges, + pagination: { + currentPage: page, + totalPages, + totalCount, + hasMore, + limit, + }, + }; + + return this.ok(res, response); + } catch (error: any) { + return this.handleError(res, error); + } + } +} diff --git a/src/modules/cards/infrastructure/http/routes/graphRoutes.ts b/src/modules/cards/infrastructure/http/routes/graphRoutes.ts index 4067fe7a..01708460 100644 --- a/src/modules/cards/infrastructure/http/routes/graphRoutes.ts +++ b/src/modules/cards/infrastructure/http/routes/graphRoutes.ts @@ -1,10 +1,12 @@ import { Router } from 'express'; import { GetGraphDataController } from '../controllers/GetGraphDataController'; +import { GetUserGraphDataController } from '../controllers/GetUserGraphDataController'; import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; export function createGraphRoutes( authMiddleware: AuthMiddleware, getGraphDataController: GetGraphDataController, + getUserGraphDataController: GetUserGraphDataController, ): Router { const router = Router(); @@ -14,5 +16,10 @@ export function createGraphRoutes( getGraphDataController.execute(req, res), ); + // GET /api/graph/user/:identifier - Get user-scoped graph data + router.get('/user/:identifier', authMiddleware.optionalAuth(), (req, res) => + getUserGraphDataController.execute(req, res), + ); + return router; } diff --git a/src/modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository.ts index 63e3ffc7..b41a9f39 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository.ts @@ -12,7 +12,11 @@ export class DrizzleGraphQueryRepository implements IGraphQueryRepository { this.graphQueryService = new GraphQueryService(db); } - async getGraphData(page?: number, limit?: number): Promise { - return this.graphQueryService.getGraphData(page, limit); + async getGraphData( + page?: number, + limit?: number, + userId?: string, + ): Promise { + return this.graphQueryService.getGraphData(page, limit, userId); } } diff --git a/src/modules/cards/infrastructure/repositories/query-services/GraphQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/GraphQueryService.ts index 1b2c4912..da024d33 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/GraphQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/GraphQueryService.ts @@ -17,6 +17,7 @@ export class GraphQueryService { async getGraphData( page: number = 1, limit: number = 300, + userId?: string, ): Promise { // Fetch all data in parallel const [ @@ -31,16 +32,16 @@ export class GraphQueryService { collectionUrlEdges, urlConnectionEdges, ] = await Promise.all([ - this.getUserNodes(), - this.getUrlNodes(), - this.getCollectionNodes(), - this.getNoteNodes(), - this.getUserFollowEdges(), - this.getCollectionFollowEdges(), - this.getAuthorshipEdges(), - this.getNoteUrlEdges(), - this.getCollectionUrlEdges(), - this.getUrlConnectionEdges(), + this.getUserNodes(userId), + this.getUrlNodes(userId), + this.getCollectionNodes(userId), + this.getNoteNodes(userId), + this.getUserFollowEdges(userId), + this.getCollectionFollowEdges(userId), + this.getAuthorshipEdges(userId), + this.getNoteUrlEdges(userId), + this.getCollectionUrlEdges(userId), + this.getUrlConnectionEdges(userId), ]); // Combine all nodes @@ -81,13 +82,49 @@ export class GraphQueryService { }; } - private async getUserNodes(): Promise { + private async getUserNodes(userId?: string): Promise { // Query all unique DIDs from all sources in the graph // Use LEFT JOIN with users table to get handles where available + // If userId is provided, filter to only include relevant DIDs const results = await this.db.execute<{ id: string; handle: string | null; - }>(sql` + }>( + userId + ? sql` + WITH all_dids AS ( + -- The target user themselves + SELECT ${userId} as did + UNION + -- Users the target user authored cards with + SELECT DISTINCT author_id as did FROM cards WHERE author_id = ${userId} + UNION + -- Users the target user follows + SELECT DISTINCT target_id as did FROM follows + WHERE follower_id = ${userId} AND target_type = 'user' + UNION + -- Users who follow the target user (for bidirectional context) + SELECT DISTINCT follower_id as did FROM follows + WHERE target_id = ${userId} AND target_type = 'user' + UNION + -- Curators of connections the target user created + SELECT DISTINCT curator_id as did FROM connections WHERE curator_id = ${userId} + UNION + -- Users who contributed to the target user's collections + SELECT DISTINCT added_by as did FROM collection_cards + WHERE collection_id IN (SELECT id FROM collections WHERE author_id = ${userId}) + UNION + -- Authors of collections the target user follows + SELECT DISTINCT author_id as did FROM collections + WHERE id::text IN (SELECT target_id FROM follows WHERE follower_id = ${userId} AND target_type = 'collection') + ) + SELECT + all_dids.did as id, + users.handle as handle + FROM all_dids + LEFT JOIN users ON all_dids.did = users.id + ` + : sql` WITH all_dids AS ( SELECT DISTINCT author_id as did FROM cards UNION @@ -106,7 +143,8 @@ export class GraphQueryService { users.handle as handle FROM all_dids LEFT JOIN users ON all_dids.did = users.id - `); + `, + ); return results.map((row) => ({ id: `user:${row.id}`, @@ -119,7 +157,56 @@ export class GraphQueryService { })); } - private async getUrlNodes(): Promise { + private async getUrlNodes(userId?: string): Promise { + if (userId) { + // For user-scoped graph: include URLs authored by user OR in their connections + const results = await this.db.execute<{ + id: string; + url: string; + content_data: any; + url_type: string | null; + }>(sql` + SELECT DISTINCT + c.id, + c.url, + c.content_data, + c.url_type + FROM cards c + WHERE c.type = 'URL' + AND c.url IS NOT NULL + AND ( + c.author_id = ${userId} + OR c.url IN ( + SELECT source_value FROM connections + WHERE curator_id = ${userId} AND source_type = 'URL' + UNION + SELECT target_value FROM connections + WHERE curator_id = ${userId} AND target_type = 'URL' + ) + ) + `); + + return results.map((row) => { + const contentData = row.content_data as any; + const title = contentData?.title || row.url || 'Untitled URL'; + + return { + id: `url:${row.url}`, + type: 'URL' as const, + label: title, + metadata: { + cardId: row.id, + url: row.url, + urlType: row.url_type, + title, + description: contentData?.description, + imageUrl: contentData?.imageUrl, + }, + }; + }); + } + + // Global graph: all URLs const results = await this.db .select({ id: cards.id, @@ -150,7 +237,45 @@ export class GraphQueryService { }); } - private async getCollectionNodes(): Promise { + private async getCollectionNodes(userId?: string): Promise { + if (userId) { + // For user-scoped graph: collections authored by user OR followed by user + const results = await this.db.execute<{ + id: string; + name: string; + description: string | null; + author_id: string; + card_count: number; + }>(sql` + SELECT + c.id, + c.name, + c.description, + c.author_id, + c.card_count + FROM collections c + WHERE c.author_id = ${userId} + OR c.id::text IN ( + SELECT target_id FROM follows + WHERE follower_id = ${userId} AND target_type = 'collection' + ) + `); + + return results.map((row) => ({ + id: `collection:${row.id}`, + type: 'COLLECTION' as const, + label: row.name, + metadata: { + collectionId: row.id, + name: row.name, + description: row.description, + authorId: row.author_id, + cardCount: row.card_count, + }, + })); + } + + // Global graph: all collections const results = await this.db .select({ id: collections.id, @@ -175,7 +300,12 @@ export class GraphQueryService { })); } - private async getNoteNodes(): Promise { + private async getNoteNodes(userId?: string): Promise { + const whereConditions = [eq(cards.type, 'NOTE')]; + if (userId) { + whereConditions.push(eq(cards.authorId, userId)); + } + const results = await this.db .select({ id: cards.id, @@ -183,7 +313,7 @@ export class GraphQueryService { authorId: cards.authorId, }) .from(cards) - .where(eq(cards.type, 'NOTE')); + .where(and(...whereConditions)); return results.map((row) => { const contentData = row.contentData as any; @@ -204,14 +334,22 @@ export class GraphQueryService { }); } - private async getUserFollowEdges(): Promise { + private async getUserFollowEdges(userId?: string): Promise { + const whereConditions = [eq(follows.targetType, 'user')]; + if (userId) { + // Include follows where user is follower OR target (bidirectional) + whereConditions.push( + sql`(${follows.followerId} = ${userId} OR ${follows.targetId} = ${userId})`, + ); + } + const results = await this.db .select({ followerId: follows.followerId, targetId: follows.targetId, }) .from(follows) - .where(eq(follows.targetType, 'user')); + .where(and(...whereConditions)); return results.map((row) => ({ id: `follow-user:${row.followerId}:${row.targetId}`, @@ -222,14 +360,22 @@ export class GraphQueryService { })); } - private async getCollectionFollowEdges(): Promise { + private async getCollectionFollowEdges( + userId?: string, + ): Promise { + const whereConditions = [eq(follows.targetType, 'collection')]; + if (userId) { + // Only include follows by the target user + whereConditions.push(eq(follows.followerId, userId)); + } + const results = await this.db .select({ followerId: follows.followerId, targetId: follows.targetId, }) .from(follows) - .where(eq(follows.targetType, 'collection')); + .where(and(...whereConditions)); return results.map((row) => ({ id: `follow-collection:${row.followerId}:${row.targetId}`, @@ -240,14 +386,22 @@ export class GraphQueryService { })); } - private async getAuthorshipEdges(): Promise { + private async getAuthorshipEdges(userId?: string): Promise { + const whereConditions = [ + eq(cards.type, 'URL'), + sql`${cards.url} IS NOT NULL`, + ]; + if (userId) { + whereConditions.push(eq(cards.authorId, userId)); + } + const results = await this.db .select({ authorId: cards.authorId, url: cards.url, }) .from(cards) - .where(and(eq(cards.type, 'URL'), sql`${cards.url} IS NOT NULL`)); + .where(and(...whereConditions)); return results.map((row) => ({ id: `authorship:${row.authorId}:${row.url}`, @@ -258,12 +412,26 @@ export class GraphQueryService { })); } - private async getNoteUrlEdges(): Promise { + private async getNoteUrlEdges(userId?: string): Promise { // Join notes with their parent URL cards using raw SQL for self-join const results = await this.db.execute<{ note_id: string; parent_url: string; - }>(sql` + }>( + userId + ? sql` + SELECT + note_cards.id as note_id, + parent_cards.url as parent_url + FROM cards as note_cards + INNER JOIN cards as parent_cards + ON note_cards.parent_card_id = parent_cards.id + AND parent_cards.type = 'URL' + WHERE note_cards.type = 'NOTE' + AND note_cards.author_id = ${userId} + AND parent_cards.url IS NOT NULL + ` + : sql` SELECT note_cards.id as note_id, parent_cards.url as parent_url @@ -273,7 +441,8 @@ export class GraphQueryService { AND parent_cards.type = 'URL' WHERE note_cards.type = 'NOTE' AND parent_cards.url IS NOT NULL - `); + `, + ); return results.map((row) => ({ id: `note-url:${row.note_id}:${row.parent_url}`, @@ -284,7 +453,48 @@ export class GraphQueryService { })); } - private async getCollectionUrlEdges(): Promise { + private async getCollectionUrlEdges( + userId?: string, + ): Promise { + if (userId) { + // For user-scoped graph: only include collections authored by or followed by the user + const results = await this.db.execute<{ + collection_id: string; + card_id: string; + url: string; + added_by: string; + }>(sql` + SELECT + cc.collection_id, + cc.card_id, + c.url, + cc.added_by + FROM collection_cards cc + INNER JOIN cards c ON cc.card_id = c.id + INNER JOIN collections col ON cc.collection_id = col.id + WHERE c.type = 'URL' + AND c.url IS NOT NULL + AND ( + col.author_id = ${userId} + OR col.id::text IN ( + SELECT target_id FROM follows + WHERE follower_id = ${userId} AND target_type = 'collection' + ) + ) + `); + + return results.map((row) => ({ + id: `collection-url:${row.collection_id}:${row.url}`, + source: `collection:${row.collection_id}`, + target: `url:${row.url}`, + type: 'COLLECTION_CONTAINS_URL' as const, + metadata: { + addedBy: row.added_by, + }, + })); + } + + // Global graph: all collection-URL edges const results = await this.db .select({ collectionId: collectionCards.collectionId, @@ -307,7 +517,18 @@ export class GraphQueryService { })); } - private async getUrlConnectionEdges(): Promise { + private async getUrlConnectionEdges( + userId?: string, + ): Promise { + const whereConditions = [ + eq(connections.sourceType, 'URL'), + eq(connections.targetType, 'URL'), + ]; + if (userId) { + // Only include connections curated by the target user + whereConditions.push(eq(connections.curatorId, userId)); + } + const results = await this.db .select({ id: connections.id, @@ -318,12 +539,7 @@ export class GraphQueryService { curatorId: connections.curatorId, }) .from(connections) - .where( - and( - eq(connections.sourceType, 'URL'), - eq(connections.targetType, 'URL'), - ), - ); + .where(and(...whereConditions)); return results.map((row) => ({ id: `connection:${row.id}`, diff --git a/src/shared/infrastructure/http/app.ts b/src/shared/infrastructure/http/app.ts index f67ec4f6..3e76c941 100644 --- a/src/shared/infrastructure/http/app.ts +++ b/src/shared/infrastructure/http/app.ts @@ -69,6 +69,7 @@ export const createExpressApp = ( const controllers = ControllerFactory.create( useCases, services.cookieService, + services, ); // Routes @@ -145,6 +146,7 @@ export const createExpressApp = ( const graphRouter = createGraphRoutes( services.authMiddleware, controllers.getGraphDataController, + controllers.getUserGraphDataController, ); const feedRouter = createFeedRoutes( diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index 4b53c2d5..fb7e52a4 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -62,7 +62,9 @@ import { GetConnectionsController } from '../../../../modules/cards/infrastructu import { GetConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetConnectionsForUrlController'; import { SearchUrlsController } from '../../../../modules/cards/infrastructure/http/controllers/SearchUrlsController'; import { GetGraphDataController } from '../../../../modules/cards/infrastructure/http/controllers/GetGraphDataController'; +import { GetUserGraphDataController } from '../../../../modules/cards/infrastructure/http/controllers/GetUserGraphDataController'; import { CookieService } from '../services/CookieService'; +import { Services } from './ServiceFactory'; export interface Controllers { // User controllers @@ -119,6 +121,7 @@ export interface Controllers { getConnectionsForUrlController: GetConnectionsForUrlController; // Graph controllers getGraphDataController: GetGraphDataController; + getUserGraphDataController: GetUserGraphDataController; // Search controllers searchUrlsController: SearchUrlsController; // Feed controllers @@ -139,7 +142,11 @@ export interface Controllers { } export class ControllerFactory { - static create(useCases: UseCases, cookieService: CookieService): Controllers { + static create( + useCases: UseCases, + cookieService: CookieService, + services: Services, + ): Controllers { return { // User controllers loginWithAppPasswordController: new LoginWithAppPasswordController( @@ -310,6 +317,9 @@ export class ControllerFactory { getGraphDataController: new GetGraphDataController( useCases.getGraphDataUseCase, ), + getUserGraphDataController: new GetUserGraphDataController( + useCases.getGraphDataUseCase, + ), // Search controllers searchUrlsController: new SearchUrlsController( diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index 03cf6241..4f1b9d67 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -431,6 +431,7 @@ export class UseCaseFactory { // Graph use cases getGraphDataUseCase: new GetGraphDataUseCase( repositories.graphQueryRepository, + services.identityResolutionService, ), // Search use cases diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index ec955426..c8fbc6f2 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -360,3 +360,7 @@ export interface GetConnectionsParams extends PaginatedSortedParams { export interface GetGraphDataParams extends PaginationParams { // Supports pagination for incremental graph loading } + +export interface GetUserGraphDataParams extends GetGraphDataParams { + identifier: string; // Can be DID or handle +} diff --git a/src/webapp/api-client/ApiClient.ts b/src/webapp/api-client/ApiClient.ts index 88fcdeb1..6f108b04 100644 --- a/src/webapp/api-client/ApiClient.ts +++ b/src/webapp/api-client/ApiClient.ts @@ -522,6 +522,14 @@ export class ApiClient { ): Promise { return this.queryClient.getGraphData(params); } + + async getUserGraphData(params: { + identifier: string; + page?: number; + limit?: number; + }): Promise { + return this.queryClient.getUserGraphData(params); + } } // Re-export types for convenience diff --git a/src/webapp/api-client/clients/QueryClient.ts b/src/webapp/api-client/clients/QueryClient.ts index 74c8512c..d7d8a946 100644 --- a/src/webapp/api-client/clients/QueryClient.ts +++ b/src/webapp/api-client/clients/QueryClient.ts @@ -553,13 +553,31 @@ export class QueryClient extends BaseClient { ); } + async getUserGraphData(params: { + identifier: string; + page?: number; + limit?: number; + }): Promise { + // Build query string with pagination parameters + const searchParams = new URLSearchParams(); + if (params?.page) searchParams.set('page', params.page.toString()); + if (params?.limit) searchParams.set('limit', params.limit.toString()); + + const queryString = searchParams.toString(); + const endpoint = queryString + ? `/api/graph/user/${params.identifier}?${queryString}` + : `/api/graph/user/${params.identifier}`; + + return this.request('GET', endpoint); + } + async getGraphData( params?: GetGraphDataParams, ): Promise { // Check if mock data should be used (for performance testing) // Set NEXT_PUBLIC_USE_MOCK_GRAPH_DATA=true in .env.local to enable - // const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_GRAPH_DATA === 'true'; - const useMockData = true; + const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_GRAPH_DATA === 'true'; + // const useMockData = true; if (useMockData) { const { generateMockGraphData, MOCK_GRAPH_PRESETS } = await import( diff --git a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/graph/page.tsx b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/graph/page.tsx new file mode 100644 index 00000000..e495c54a --- /dev/null +++ b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/graph/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react'; +import { Box, LoadingOverlay } from '@mantine/core'; +import UserGraphView from '@/features/graph/components/graphView/UserGraphView'; + +interface PageProps { + params: Promise<{ + handle: string; + }>; +} + +export async function generateMetadata({ params }: PageProps) { + const { handle } = await params; + return { + title: `${handle}'s Graph | Semble`, + description: `Visualize ${handle}'s knowledge graph`, + }; +} + +export default async function UserGraphPage({ params }: PageProps) { + const { handle } = await params; + + return ( + + + + } + > + + + ); +} diff --git a/src/webapp/features/graph/components/graphView/UserGraphView.tsx b/src/webapp/features/graph/components/graphView/UserGraphView.tsx new file mode 100644 index 00000000..b3ae957c --- /dev/null +++ b/src/webapp/features/graph/components/graphView/UserGraphView.tsx @@ -0,0 +1,471 @@ +'use client'; + +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import dynamic from 'next/dynamic'; +import { Box, LoadingOverlay } from '@mantine/core'; +import useUserGraphData from '../../lib/queries/useUserGraphData'; +import type { + ExtendedGraphNode, + ExtendedGraphEdge, + PopupPosition, +} from '../../types'; +import { + getNodeColor, + getNodeSecondaryColor, + NODE_SIZE, +} from '../../lib/utils/nodeStyles'; +import { + PHYSICS_CONFIG, + VISUAL_CONFIG, + INTERACTION_CONFIG, +} from '../../lib/utils/graphConfig'; +import NodePopupPreview from '../nodePopups/NodePopupPreview'; +import NodePopupDetail from '../nodePopups/NodePopupDetail'; +import GraphFilterPanel from './GraphFilterPanel'; +import { useRouter } from 'next/navigation'; +import styles from './GraphView.module.css'; + +// Dynamically import ForceGraph2D to avoid SSR issues +const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { + ssr: false, + loading: () => ( + + + + ), +}); + +// Type definitions for filters +type NodeType = 'USER' | 'COLLECTION' | 'URL' | 'NOTE'; +type EdgeType = + | 'USER_FOLLOWS_USER' + | 'USER_FOLLOWS_COLLECTION' + | 'USER_AUTHORED_URL' + | 'NOTE_REFERENCES_URL' + | 'COLLECTION_CONTAINS_URL' + | 'URL_CONNECTS_URL'; + +interface UserGraphViewProps { + identifier: string; +} + +export default function UserGraphView({ identifier }: UserGraphViewProps) { + const router = useRouter(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const graphRef = useRef(undefined); + + // State for dual popup system + const [hoverNode, setHoverNode] = useState(null); + const [pinnedNode, setPinnedNode] = useState(null); + const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 }); + const [detailPos, setDetailPos] = useState({ x: 0, y: 0 }); + + // State for graph filters (all types visible by default) + const [visibleNodeTypes, setVisibleNodeTypes] = useState>( + new Set(['USER', 'COLLECTION', 'URL', 'NOTE'] as NodeType[]), + ); + const [visibleEdgeTypes, setVisibleEdgeTypes] = useState>( + new Set([ + 'USER_FOLLOWS_USER', + 'USER_FOLLOWS_COLLECTION', + 'USER_AUTHORED_URL', + 'NOTE_REFERENCES_URL', + 'COLLECTION_CONTAINS_URL', + 'URL_CONNECTS_URL', + ] as EdgeType[]), + ); + + // Fetch and process graph data for the specific user + const { data: graphData } = useUserGraphData(identifier); + + // Filter graph data based on visible types + const filteredGraphData = useMemo(() => { + if (!graphData) return null; + + // Filter nodes by visible types + const filteredNodes = graphData.nodes.filter((node) => + visibleNodeTypes.has(node.type as NodeType), + ); + + // Create a set of visible node IDs for efficient lookup + const visibleNodeIds = new Set(filteredNodes.map((node) => node.id)); + + // Filter edges by visible edge types AND ensure both endpoints are visible + const filteredLinks = graphData.links.filter((edge) => { + // Check if edge type is visible + if (!visibleEdgeTypes.has(edge.type as EdgeType)) return false; + + // Get source and target IDs (handles both string and object references) + const sourceId = + typeof edge.source === 'string' + ? edge.source + : (edge.source as ExtendedGraphNode).id; + const targetId = + typeof edge.target === 'string' + ? edge.target + : (edge.target as ExtendedGraphNode).id; + + // Only include edge if both nodes are visible + return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId); + }); + + return { + nodes: filteredNodes, + links: filteredLinks, + }; + }, [graphData, visibleNodeTypes, visibleEdgeTypes]); + + // Toggle handlers for filter panel + const handleNodeTypeToggle = useCallback((type: NodeType) => { + setVisibleNodeTypes((prev) => { + const newSet = new Set(prev); + if (newSet.has(type)) { + newSet.delete(type); + } else { + newSet.add(type); + } + return newSet; + }); + }, []); + + const handleEdgeTypeToggle = useCallback((type: EdgeType) => { + setVisibleEdgeTypes((prev) => { + const newSet = new Set(prev); + if (newSet.has(type)) { + newSet.delete(type); + } else { + newSet.add(type); + } + return newSet; + }); + }, []); + + // Track previous node count to detect new nodes and trigger smooth transitions + const prevNodeCountRef = useRef(0); + const animationFrameRef = useRef(null); + + // Detect when new nodes are added and gently reheat simulation + useEffect(() => { + if (!filteredGraphData || !graphRef.current) return; + + const currentNodeCount = filteredGraphData.nodes.length; + const hasNewNodes = currentNodeCount > prevNodeCountRef.current; + + if (hasNewNodes && prevNodeCountRef.current > 0) { + // Gently reheat the simulation for smooth repositioning + // Lower alpha = gentler movement + graphRef.current.d3ReheatSimulation(); + graphRef.current.d3Force('charge')?.strength(-30); // Softer repulsion during transition + + // Restore normal force after animation completes + setTimeout(() => { + if (graphRef.current?.d3Force) { + graphRef.current.d3Force('charge')?.strength(-60); + } + }, 1000); + } + + prevNodeCountRef.current = currentNodeCount; + }, [filteredGraphData]); + + // Animation loop to continuously re-render for fade-in effect + useEffect(() => { + if (!graphData) return; + + const animate = () => { + // Check if any nodes are still fading in (added within last 800ms) + const now = Date.now(); + const hasFadingNodes = graphData.nodes.some( + (node) => node.__addedAt && now - node.__addedAt < 800, + ); + + if (hasFadingNodes && graphRef.current) { + // Trigger a re-render by calling refresh + graphRef.current._destructor?.(); // Force canvas redraw + } + + if (hasFadingNodes) { + animationFrameRef.current = requestAnimationFrame(animate); + } else { + animationFrameRef.current = null; + } + }; + + // Start animation if we have new nodes + if (!animationFrameRef.current) { + const now = Date.now(); + const hasNewNodes = graphData.nodes.some( + (node) => node.__addedAt && now - node.__addedAt < 800, + ); + if (hasNewNodes) { + animationFrameRef.current = requestAnimationFrame(animate); + } + } + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [graphData]); + + // Handle node hover (preview popup) + const handleNodeHover = useCallback( + (node: any) => { + const typedNode = node as ExtendedGraphNode | null; + if ( + typedNode && + !pinnedNode && + typedNode.x !== undefined && + typedNode.y !== undefined + ) { + setHoverNode(typedNode); + // Calculate screen coordinates for the node + if (graphRef.current) { + const screenPos = graphRef.current.graph2ScreenCoords( + typedNode.x, + typedNode.y, + ); + setPreviewPos({ x: screenPos.x + 15, y: screenPos.y - 10 }); + } + } else if (!pinnedNode) { + setHoverNode(null); + } + }, + [pinnedNode], + ); + + // Handle node click (detail popup) + const handleNodeClick = useCallback((node: any) => { + const typedNode = node as ExtendedGraphNode; + setPinnedNode(typedNode); + setHoverNode(null); // Hide preview when pinning detail + + // Convert graph coordinates to screen coordinates + if ( + graphRef.current && + typedNode.x !== undefined && + typedNode.y !== undefined + ) { + const screenPos = graphRef.current.graph2ScreenCoords( + typedNode.x, + typedNode.y, + ); + // Offset to the right of the node + setDetailPos({ x: screenPos.x + 20, y: screenPos.y }); + } + }, []); + + // Handle background click (close detail popup) + const handleBackgroundClick = useCallback(() => { + setPinnedNode(null); + }, []); + + // Update detail popup position during zoom/pan + const handleZoomPan = useCallback(() => { + if ( + pinnedNode && + graphRef.current && + pinnedNode.x !== undefined && + pinnedNode.y !== undefined + ) { + const screenPos = graphRef.current.graph2ScreenCoords( + pinnedNode.x, + pinnedNode.y, + ); + setDetailPos({ x: screenPos.x + 20, y: screenPos.y }); + } + }, [pinnedNode]); + + // Handle navigation from popups + const handleNavigate = useCallback( + (nodeId: string) => { + const node = filteredGraphData?.nodes.find((n) => n.id === nodeId); + if (!node) return; + + let route: string; + switch (node.type) { + case 'USER': + route = `/profile/${node.metadata.handle}`; + break; + case 'COLLECTION': + route = `/collections/${node.metadata.handle}/${node.metadata.rkey}`; + break; + case 'URL': + route = `/url?id=${encodeURIComponent(node.metadata.url)}`; + break; + case 'NOTE': + route = `/url?id=${encodeURIComponent(node.metadata.parentUrl)}`; + break; + default: + return; + } + + router.push(route); + }, + [filteredGraphData, router], + ); + + // Close detail popup + const handleCloseDetail = useCallback(() => { + setPinnedNode(null); + }, []); + + // Custom node canvas renderer + const nodeCanvasObject = useCallback( + (nodeData: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const node = nodeData as ExtendedGraphNode; + if (node.x === undefined || node.y === undefined) return; + + const size = + (node.val || NODE_SIZE.DEFAULT) * (1 / Math.sqrt(globalScale)); + const isSelected = node === pinnedNode || node === hoverNode; + + // Calculate opacity for fade-in animation (800ms duration) + let opacity = 1; + if (node.__addedAt) { + const age = Date.now() - node.__addedAt; + const fadeDuration = 800; // ms + if (age < fadeDuration) { + // Ease-in opacity from 0 to 1 + opacity = Math.min(1, age / fadeDuration); + // Ease-out cubic for smoother animation + opacity = 1 - Math.pow(1 - opacity, 3); + } + } + + // Save context state for opacity + ctx.save(); + ctx.globalAlpha = opacity; + + // Create gradient for node fill + const gradient = ctx.createRadialGradient( + node.x, + node.y, + 0, + node.x, + node.y, + size * 1.5, + ); + const primaryColor = getNodeColor(node.type); + const secondaryColor = getNodeSecondaryColor(node.type); + gradient.addColorStop(0, primaryColor); + gradient.addColorStop( + 1, + isSelected ? VISUAL_CONFIG.node.shadowColor : secondaryColor, + ); + + // Draw node circle + ctx.beginPath(); + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); + ctx.fillStyle = gradient; + ctx.fill(); + + // Add glow effect for selected nodes + if (isSelected) { + ctx.shadowColor = VISUAL_CONFIG.node.shadowColor; + ctx.shadowBlur = VISUAL_CONFIG.node.shadowBlurSelected; + } + + // Draw border + ctx.strokeStyle = VISUAL_CONFIG.node.borderColor; + ctx.lineWidth = isSelected + ? VISUAL_CONFIG.node.borderWidthSelected + : VISUAL_CONFIG.node.borderWidth; + ctx.stroke(); + + // Reset shadow + ctx.shadowBlur = VISUAL_CONFIG.node.shadowBlur; + + // Draw connection count badge for highly connected nodes + if (node.connectionCount && node.connectionCount >= 5) { + const badgeSize = 4 / globalScale; + ctx.beginPath(); + ctx.arc(node.x + size, node.y - size, badgeSize, 0, 2 * Math.PI); + ctx.fillStyle = VISUAL_CONFIG.node.shadowColor; + ctx.fill(); + } + + // Restore context state (opacity) + ctx.restore(); + }, + [pinnedNode, hoverNode], + ); + + // Custom node pointer area for better hit detection + const nodePointerAreaPaint = useCallback( + (nodeData: any, color: string, ctx: CanvasRenderingContext2D) => { + const node = nodeData as ExtendedGraphNode; + if (node.x === undefined || node.y === undefined) return; + + const size = + (node.val || NODE_SIZE.DEFAULT) * INTERACTION_CONFIG.hitAreaMultiplier; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); + ctx.fill(); + }, + [], + ); + + if (!filteredGraphData) { + return ( + + + + ); + } + + return ( + + {/* Filter Panel */} + + + VISUAL_CONFIG.link.color} + linkWidth={() => VISUAL_CONFIG.link.width} + linkDirectionalArrowLength={VISUAL_CONFIG.arrow.length} + linkDirectionalArrowRelPos={VISUAL_CONFIG.arrow.relativePosition} + warmupTicks={PHYSICS_CONFIG.warmupTicks} + cooldownTicks={PHYSICS_CONFIG.cooldownTicks} + d3AlphaDecay={PHYSICS_CONFIG.d3AlphaDecay} + d3VelocityDecay={PHYSICS_CONFIG.d3VelocityDecay} + enableNodeDrag={INTERACTION_CONFIG.enableNodeDrag} + enableZoomInteraction={INTERACTION_CONFIG.enableZoom} + enablePanInteraction={INTERACTION_CONFIG.enablePan} + minZoom={INTERACTION_CONFIG.minZoom} + maxZoom={INTERACTION_CONFIG.maxZoom} + /> + + {/* Hover preview popup */} + {hoverNode && !pinnedNode && ( + + )} + + {/* Click detail popup */} + {pinnedNode && ( + + )} + + ); +} diff --git a/src/webapp/features/graph/lib/graphKeys.ts b/src/webapp/features/graph/lib/graphKeys.ts index e9e1ed4a..780e22b2 100644 --- a/src/webapp/features/graph/lib/graphKeys.ts +++ b/src/webapp/features/graph/lib/graphKeys.ts @@ -6,5 +6,9 @@ export const graphKeys = { all: () => ['graph'] as const, data: () => [...graphKeys.all(), 'data'] as const, page: (page: number) => [...graphKeys.all(), 'page', page] as const, + user: (identifier: string) => + [...graphKeys.all(), 'user', identifier] as const, + userPage: (identifier: string, page: number) => + [...graphKeys.all(), 'user', identifier, 'page', page] as const, node: (nodeId: string) => [...graphKeys.all(), 'node', nodeId] as const, } as const; diff --git a/src/webapp/features/graph/lib/queries/useUserGraphData.tsx b/src/webapp/features/graph/lib/queries/useUserGraphData.tsx new file mode 100644 index 00000000..790d2e8a --- /dev/null +++ b/src/webapp/features/graph/lib/queries/useUserGraphData.tsx @@ -0,0 +1,185 @@ +import { useQueries } from '@tanstack/react-query'; +import { useState, useEffect, useRef } from 'react'; +import { graphKeys } from '../graphKeys'; +import { apiClient } from '@/api-client/ApiClient'; +import type { + ProcessedGraphData, + ExtendedGraphNode, + ExtendedGraphEdge, +} from '../../types'; +import { calculateNodeSize, getNodeColor } from '../utils/nodeStyles'; +import type { GetGraphDataResponse } from '@semble/types'; + +/** + * Hook to fetch and process user-scoped graph data with incremental loading + * Automatically loads data in pages and progressively renders + * Calculates connection counts, node sizes, and colors for all loaded data + */ +export default function useUserGraphData(identifier: string) { + const [pagesToLoad, setPagesToLoad] = useState([1]); + const [totalPages, setTotalPages] = useState(null); + + // Store processed data in state for incremental updates + const [processedData, setProcessedData] = useState({ + nodes: [], + links: [], + }); + + // Track which pages we've already processed to avoid reprocessing + const processedPagesRef = useRef>(new Set()); + + // Maintain stable node references (important for smooth graph transitions) + const nodeMapRef = useRef>(new Map()); + + // Fetch pages in parallel (React Query will dedupe and cache) + const queries = useQueries({ + queries: pagesToLoad.map((page) => ({ + queryKey: graphKeys.userPage(identifier, page), + queryFn: () => apiClient.getUserGraphData({ identifier, page }), + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + })), + }); + + // Incrementally merge new pages as they complete + useEffect(() => { + const successfulQueries = queries.filter((q) => q.isSuccess && q.data); + if (successfulQueries.length === 0) return; + + // Find pages we haven't processed yet + const newPages: { page: number; data: GetGraphDataResponse }[] = []; + successfulQueries.forEach((q, idx) => { + const page = pagesToLoad[idx]; + if (!processedPagesRef.current.has(page)) { + newPages.push({ page, data: q.data as GetGraphDataResponse }); + } + }); + + // Update pagination metadata and queue next page + const latestData = successfulQueries[successfulQueries.length - 1] + .data as GetGraphDataResponse; + + if (latestData?.pagination) { + setTotalPages(latestData.pagination.totalPages); + + // Queue next page if available + if ( + latestData.pagination.hasMore && + !pagesToLoad.includes(latestData.pagination.currentPage + 1) + ) { + setPagesToLoad((prev) => [ + ...prev, + latestData.pagination.currentPage + 1, + ]); + } + } + + // If no new pages, nothing to merge + if (newPages.length === 0) return; + + // Mark pages as processed + newPages.forEach(({ page }) => processedPagesRef.current.add(page)); + + // Incrementally merge new data + setProcessedData((prevData) => { + // Extract new nodes and edges from newly loaded pages + const newNodes = newPages.flatMap(({ data }) => data.nodes); + const newEdges = newPages.flatMap(({ data }) => data.edges); + + // Add new nodes to map (preserving existing node references) + newNodes.forEach((node) => { + if (!nodeMapRef.current.has(node.id)) { + // Create new extended node with timestamp for fade-in animation + const extendedNode: ExtendedGraphNode = { + ...node, + connectionCount: 0, + val: 0, + color: getNodeColor(node.type), + // Track when node was added for smooth fade-in + __addedAt: Date.now(), + }; + nodeMapRef.current.set(node.id, extendedNode); + } + }); + + // Combine all edges (previous + new) + const allEdges = [...prevData.links, ...newEdges]; + + // Recalculate connection counts for ALL nodes (new edges affect existing nodes) + const connectionCounts: Record = {}; + allEdges.forEach((edge) => { + // Handle both string IDs and object references (ForceGraph2D mutates edges) + const sourceId = + typeof edge.source === 'string' + ? edge.source + : (edge.source as ExtendedGraphNode).id; + const targetId = + typeof edge.target === 'string' + ? edge.target + : (edge.target as ExtendedGraphNode).id; + + connectionCounts[sourceId] = (connectionCounts[sourceId] || 0) + 1; + connectionCounts[targetId] = (connectionCounts[targetId] || 0) + 1; + }); + + // Update connection counts on existing node objects (mutate in place) + nodeMapRef.current.forEach((node) => { + const connectionCount = connectionCounts[node.id] || 0; + node.connectionCount = connectionCount; + node.val = calculateNodeSize(connectionCount); + // color stays the same + }); + + // Get all nodes as array (stable references for existing nodes) + const allNodes = Array.from(nodeMapRef.current.values()); + + // Create a set of valid node IDs for edge validation + const validNodeIds = new Set(allNodes.map((n) => n.id)); + + // Process edges (add value for thickness if needed) + // Filter out edges that reference non-existent nodes + const processedEdges: ExtendedGraphEdge[] = allEdges + .filter((edge) => { + const sourceId = + typeof edge.source === 'string' + ? edge.source + : (edge.source as ExtendedGraphNode).id; + const targetId = + typeof edge.target === 'string' + ? edge.target + : (edge.target as ExtendedGraphNode).id; + return validNodeIds.has(sourceId) && validNodeIds.has(targetId); + }) + .map((edge) => ({ + ...edge, + value: 1, // Default thickness, can be customized based on edge type + })); + + return { + nodes: allNodes, + links: processedEdges, + }; + }); + }, [queries, pagesToLoad]); + + // Aggregate query state + const isLoading = queries.some((q) => q.isLoading); + const isError = queries.some((q) => q.isError); + const error = queries.find((q) => q.error)?.error; + + // Calculate overall loading progress + const loadedPages = queries.filter((q) => q.isSuccess).length; + const loadingProgress = + totalPages && totalPages > 0 ? (loadedPages / totalPages) * 100 : 0; + + return { + data: processedData, + isLoading, + isError, + error, + loadingProgress, + loadedPages, + totalPages, + isComplete: totalPages !== null && loadedPages >= totalPages, + }; +}