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 */}
+
+