From 4bbf679ae7895d62e26e7c36a06781bb82cf8797 Mon Sep 17 00:00:00 2001 From: jigang zhou Date: Mon, 16 Mar 2026 04:38:17 -0700 Subject: [PATCH] feat(web): add node search with highlight/dim to Graph tab Closes #2 - Add GraphSearch component: search input with clear button (lucide icons) - Integrate search into GraphTab visualization column header - Matching nodes stay fully visible and grow slightly with glow effect - Non-matching nodes fade to 15% opacity in real-time as user types - Node lists below the SVG also filter to matching results only - No external dependencies added --- packages/web/src/components/GraphSearch.tsx | 49 +++++++++++++++++++ packages/web/src/components/Tabs/GraphTab.tsx | 47 +++++++++++++----- 2 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 packages/web/src/components/GraphSearch.tsx diff --git a/packages/web/src/components/GraphSearch.tsx b/packages/web/src/components/GraphSearch.tsx new file mode 100644 index 0000000..88824b3 --- /dev/null +++ b/packages/web/src/components/GraphSearch.tsx @@ -0,0 +1,49 @@ +import { useState, useCallback } from 'react'; +import { Search, X } from 'lucide-react'; + +interface GraphSearchProps { + onSearch: (query: string) => void; + placeholder?: string; +} + +export const GraphSearch = ({ onSearch, placeholder = 'Search nodes by URL…' }: GraphSearchProps) => { + const [value, setValue] = useState(''); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const q = e.target.value; + setValue(q); + onSearch(q); + }, + [onSearch] + ); + + const handleClear = useCallback(() => { + setValue(''); + onSearch(''); + }, [onSearch]); + + return ( +
+ + + {value && ( + + )} +
+ ); +}; diff --git a/packages/web/src/components/Tabs/GraphTab.tsx b/packages/web/src/components/Tabs/GraphTab.tsx index 4f8b154..48207d2 100644 --- a/packages/web/src/components/Tabs/GraphTab.tsx +++ b/packages/web/src/components/Tabs/GraphTab.tsx @@ -1,10 +1,22 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import * as API from '../../api'; import { Share2, HelpCircle } from 'lucide-react'; +import { GraphSearch } from '../GraphSearch'; export const GraphTab = ({ url, snapshotId }: { url: string; snapshotId: number }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const handleSearch = useCallback((q: string) => { + setSearchQuery(q.trim().toLowerCase()); + }, []); + + /** Returns true if a node URL matches the search query */ + const matchesQuery = (nodeUrl: string | undefined | null): boolean => { + if (!searchQuery) return true; + return (nodeUrl || '').toLowerCase().includes(searchQuery); + }; useEffect(() => { if (!snapshotId) return; @@ -72,14 +84,17 @@ export const GraphTab = ({ url, snapshotId }: { url: string; snapshotId: number {/* Visualization Column */}
-
- Immediate Neighbors (Depth 1) -
- -
- This visualization shows internal pages linking directly to this page (Green) and pages this page links to (Amber). Node size indicates logical importance. +
+
+ Immediate Neighbors (Depth 1) +
+ +
+ This visualization shows internal pages linking directly to this page (Green) and pages this page links to (Amber). Node size indicates logical importance. +
+
{/* SVG Visualization */} @@ -97,8 +112,10 @@ export const GraphTab = ({ url, snapshotId }: { url: string; snapshotId: number const y = (i - (arrayLength - 1) / 2) * 45; const shortUrl = (node.normalized_url || '').split('/').pop() || '/'; const nodeHref = `/page?url=${encodeURIComponent(node.normalized_url || '')}${siteId ? `&siteId=${encodeURIComponent(siteId)}` : ''}`; + const matched = matchesQuery(node.normalized_url); + const dimmed = searchQuery && !matched; return ( - + {/* Incoming flow: source node -> current node */} @@ -109,7 +126,8 @@ export const GraphTab = ({ url, snapshotId }: { url: string; snapshotId: number path={`M ${x} ${y} L -25 0`} /> - + {shortUrl} @@ -126,8 +144,10 @@ export const GraphTab = ({ url, snapshotId }: { url: string; snapshotId: number const y = (i - (arrayLength - 1) / 2) * 45; const shortUrl = (node.normalized_url || '').split('/').pop() || '/'; const nodeHref = `/page?url=${encodeURIComponent(node.normalized_url || '')}${siteId ? `&siteId=${encodeURIComponent(siteId)}` : ''}`; + const matched = matchesQuery(node.normalized_url); + const dimmed = searchQuery && !matched; return ( - + {/* Outgoing flow: current node -> target node */} @@ -138,7 +158,8 @@ export const GraphTab = ({ url, snapshotId }: { url: string; snapshotId: number path={`M 25 0 L ${x} ${y}`} /> - + {shortUrl} @@ -163,7 +184,7 @@ export const GraphTab = ({ url, snapshotId }: { url: string; snapshotId: number

No incoming internal nodes for this snapshot.

) : (
    - {(data.incoming || []).map((node, idx) => ( + {(data.incoming || []).filter(n => matchesQuery(n.normalized_url)).map((node, idx) => (
  • {node.normalized_url} PR {(node.pagerank_score || 0).toFixed(2)} @@ -179,7 +200,7 @@ export const GraphTab = ({ url, snapshotId }: { url: string; snapshotId: number

    No outgoing internal nodes for this snapshot.

    ) : (
      - {(data.outgoing || []).map((node, idx) => ( + {(data.outgoing || []).filter(n => matchesQuery(n.normalized_url)).map((node, idx) => (
    • {node.normalized_url} PR {(node.pagerank_score || 0).toFixed(2)}