diff --git a/src/webapp/app/(dashboard)/graph/layout.tsx b/src/webapp/app/(dashboard)/graph/layout.tsx new file mode 100644 index 00000000..cd2fb040 --- /dev/null +++ b/src/webapp/app/(dashboard)/graph/layout.tsx @@ -0,0 +1,21 @@ +import Header from '@/components/navigation/header/Header'; +import type { Metadata } from 'next'; +import { Fragment } from 'react'; + +export const metadata: Metadata = { + title: 'Graph', + description: 'Graph', +}; + +interface Props { + children: React.ReactNode; +} + +export default function Layout(props: Props) { + return ( + +
+ {props.children} + + ); +} diff --git a/src/webapp/features/graph/components/graphView/GraphFilterPanel.module.css b/src/webapp/features/graph/components/graphView/GraphFilterPanel.module.css new file mode 100644 index 00000000..2a391e74 --- /dev/null +++ b/src/webapp/features/graph/components/graphView/GraphFilterPanel.module.css @@ -0,0 +1,50 @@ +.panel { + position: absolute; + top: 16px; + right: 16px; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 16px; + z-index: 10; + min-width: 240px; + max-width: 280px; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.panel.collapsed { + min-width: auto; + padding: 8px; + width: 40px; +} + +.toggleButton { + position: absolute; + top: 8px; + right: 8px; + color: rgba(255, 255, 255, 0.7); + transition: color 0.2s ease; +} + +.toggleButton:hover { + color: rgba(255, 255, 255, 1); +} + +.panel.collapsed .toggleButton { + position: static; + margin: 0 auto; + display: block; +} + +.content { + opacity: 1; + transition: opacity 0.2s ease; + padding-right: 24px; +} + +.panel.collapsed .content { + opacity: 0; + pointer-events: none; +} diff --git a/src/webapp/features/graph/components/graphView/GraphFilterPanel.tsx b/src/webapp/features/graph/components/graphView/GraphFilterPanel.tsx new file mode 100644 index 00000000..0ecfdfde --- /dev/null +++ b/src/webapp/features/graph/components/graphView/GraphFilterPanel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState } from 'react'; +import { Box, Switch, Stack, Text, ActionIcon, Group } from '@mantine/core'; +import { IoChevronBack, IoChevronForward } from 'react-icons/io5'; +import styles from './GraphFilterPanel.module.css'; + +// All possible node and edge types in the graph +const NODE_TYPES = ['USER', 'COLLECTION', 'URL', 'NOTE'] as const; +const EDGE_TYPES = [ + 'USER_FOLLOWS_USER', + 'USER_FOLLOWS_COLLECTION', + 'USER_AUTHORED_URL', + 'NOTE_REFERENCES_URL', + 'COLLECTION_CONTAINS_URL', + 'URL_CONNECTS_URL', +] as const; + +type NodeType = (typeof NODE_TYPES)[number]; +type EdgeType = (typeof EDGE_TYPES)[number]; + +interface GraphFilterPanelProps { + visibleNodeTypes: Set; + visibleEdgeTypes: Set; + onNodeTypeToggle: (type: NodeType) => void; + onEdgeTypeToggle: (type: EdgeType) => void; +} + +// Human-readable labels for types +const NODE_TYPE_LABELS: Record = { + USER: 'Users', + COLLECTION: 'Collections', + URL: 'URLs', + NOTE: 'Notes', +}; + +const EDGE_TYPE_LABELS: Record = { + USER_FOLLOWS_USER: 'User → User', + USER_FOLLOWS_COLLECTION: 'User → Collection', + USER_AUTHORED_URL: 'User → URL', + NOTE_REFERENCES_URL: 'Note → URL', + COLLECTION_CONTAINS_URL: 'Collection → URL', + URL_CONNECTS_URL: 'URL → URL', +}; + +export default function GraphFilterPanel({ + visibleNodeTypes, + visibleEdgeTypes, + onNodeTypeToggle, + onEdgeTypeToggle, +}: GraphFilterPanelProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + + return ( + + {/* Collapse/Expand Button */} + setIsCollapsed(!isCollapsed)} + variant="subtle" + size="sm" + aria-label={isCollapsed ? 'Expand panel' : 'Collapse panel'} + > + {isCollapsed ? ( + + ) : ( + + )} + + + {/* Panel Content */} + {!isCollapsed && ( + + + Graph Filters + + + {/* Node Type Filters */} + + + Node Types + + {NODE_TYPES.map((type) => ( + + {NODE_TYPE_LABELS[type]} + onNodeTypeToggle(type)} + size="sm" + /> + + ))} + + + {/* Edge Type Filters */} + + + Edge Types + + {EDGE_TYPES.map((type) => ( + + {EDGE_TYPE_LABELS[type]} + onEdgeTypeToggle(type)} + size="sm" + /> + + ))} + + + )} + + ); +} diff --git a/src/webapp/features/graph/components/graphView/GraphView.module.css b/src/webapp/features/graph/components/graphView/GraphView.module.css index df1fa16e..cea6ff7c 100644 --- a/src/webapp/features/graph/components/graphView/GraphView.module.css +++ b/src/webapp/features/graph/components/graphView/GraphView.module.css @@ -1,6 +1,7 @@ .root { cursor: grab; user-select: none; + overflow: hidden; } .root:active { diff --git a/src/webapp/features/graph/components/graphView/GraphView.tsx b/src/webapp/features/graph/components/graphView/GraphView.tsx index 846568de..ec46b80c 100644 --- a/src/webapp/features/graph/components/graphView/GraphView.tsx +++ b/src/webapp/features/graph/components/graphView/GraphView.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef, useCallback, useEffect } from 'react'; +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import dynamic from 'next/dynamic'; import { Box, LoadingOverlay } from '@mantine/core'; import useGraphData from '../../lib/queries/useGraphData'; @@ -21,6 +21,7 @@ import { } 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'; @@ -34,6 +35,16 @@ const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ), }); +// 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'; + export default function GraphView() { const router = useRouter(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -45,18 +56,95 @@ export default function GraphView() { 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 const { data: graphData } = useGraphData(); + // 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 (!graphData || !graphRef.current) return; + if (!filteredGraphData || !graphRef.current) return; - const currentNodeCount = graphData.nodes.length; + const currentNodeCount = filteredGraphData.nodes.length; const hasNewNodes = currentNodeCount > prevNodeCountRef.current; if (hasNewNodes && prevNodeCountRef.current > 0) { @@ -74,7 +162,7 @@ export default function GraphView() { } prevNodeCountRef.current = currentNodeCount; - }, [graphData]); + }, [filteredGraphData]); // Animation loop to continuously re-render for fade-in effect useEffect(() => { @@ -188,7 +276,7 @@ export default function GraphView() { // Handle navigation from popups const handleNavigate = useCallback( (nodeId: string) => { - const node = graphData?.nodes.find((n) => n.id === nodeId); + const node = filteredGraphData?.nodes.find((n) => n.id === nodeId); if (!node) return; let route: string; @@ -211,7 +299,7 @@ export default function GraphView() { router.push(route); }, - [graphData, router], + [filteredGraphData, router], ); // Close detail popup @@ -316,7 +404,7 @@ export default function GraphView() { [], ); - if (!graphData) { + if (!filteredGraphData) { return ( @@ -326,9 +414,17 @@ export default function GraphView() { return ( + {/* Filter Panel */} + +