Refactor graph search to update search engin after node expand or prune

This commit is contained in:
yangdx
2025-03-15 10:52:47 +08:00
parent fdaf199b15
commit f4fceca7f3
4 changed files with 87 additions and 27 deletions

View File

@@ -10,29 +10,27 @@ import { searchResultLimit } from '@/lib/constants'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { OptionItem } from './graphSearchTypes'
import { messageId, searchCache } from './graphSearchUtils'
interface OptionItem { const NodeOption = ({ id }: { id: string }) => {
id: string const graph = useGraphStore.use.sigmaGraph()
type: 'nodes' | 'edges' | 'message' if (!graph?.hasNode(id)) {
message?: string return null
}
return <NodeById id={id} />
} }
function OptionComponent(item: OptionItem) { function OptionComponent(item: OptionItem) {
return ( return (
<div> <div>
{item.type === 'nodes' && <NodeById id={item.id} />} {item.type === 'nodes' && <NodeOption id={item.id} />}
{item.type === 'edges' && <EdgeById id={item.id} />} {item.type === 'edges' && <EdgeById id={item.id} />}
{item.type === 'message' && <div>{item.message}</div>} {item.type === 'message' && <div>{item.message}</div>}
</div> </div>
) )
} }
const messageId = '__message_item'
// Reset this cache when graph changes to ensure fresh search results
const lastGraph: any = {
graph: null,
searchEngine: null
}
/** /**
* Component thats display the search input. * Component thats display the search input.
@@ -53,18 +51,18 @@ export const GraphSearchInput = ({
useEffect(() => { useEffect(() => {
if (graph) { if (graph) {
// Reset cache to ensure fresh search results with new graph data // Reset cache to ensure fresh search results with new graph data
lastGraph.graph = null; searchCache.graph = null;
lastGraph.searchEngine = null; searchCache.searchEngine = null;
} }
}, [graph]); }, [graph]);
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
if (lastGraph.graph == graph) { if (searchCache.graph == graph) {
return lastGraph.searchEngine return searchCache.searchEngine
} }
if (!graph || graph.nodes().length == 0) return if (!graph || graph.nodes().length == 0) return
lastGraph.graph = graph searchCache.graph = graph
const searchEngine = new MiniSearch({ const searchEngine = new MiniSearch({
idField: 'id', idField: 'id',
@@ -85,7 +83,7 @@ export const GraphSearchInput = ({
})) }))
searchEngine.addAll(documents) searchEngine.addAll(documents)
lastGraph.searchEngine = searchEngine searchCache.searchEngine = searchEngine
return searchEngine return searchEngine
}, [graph]) }, [graph])
@@ -95,19 +93,35 @@ export const GraphSearchInput = ({
const loadOptions = useCallback( const loadOptions = useCallback(
async (query?: string): Promise<OptionItem[]> => { async (query?: string): Promise<OptionItem[]> => {
if (onFocus) onFocus(null) if (onFocus) onFocus(null)
if (!graph || !searchEngine) return []
// If no query, return first searchResultLimit nodes // Safety checks to prevent crashes
if (!graph || !searchEngine) {
// Reset cache to ensure fresh search engine initialization on next render
searchCache.graph = null
searchCache.searchEngine = null
return []
}
// Verify graph has nodes before proceeding
if (graph.nodes().length === 0) {
return []
}
// If no query, return first searchResultLimit nodes that exist
if (!query) { if (!query) {
const nodeIds = graph.nodes().slice(0, searchResultLimit) const nodeIds = graph.nodes()
.filter(id => graph.hasNode(id))
.slice(0, searchResultLimit)
return nodeIds.map(id => ({ return nodeIds.map(id => ({
id, id,
type: 'nodes' type: 'nodes'
})) }))
} }
// If has query, search nodes // If has query, search nodes and verify they still exist
const result: OptionItem[] = searchEngine.search(query).map((r: { id: string }) => ({ const result: OptionItem[] = searchEngine.search(query)
.filter((r: { id: string }) => graph.hasNode(r.id))
.map((r: { id: string }) => ({
id: r.id, id: r.id,
type: 'nodes' type: 'nodes'
})) }))

View File

@@ -0,0 +1,5 @@
export interface OptionItem {
id: string
type: 'nodes' | 'edges' | 'message'
message?: string
}

View File

@@ -0,0 +1,28 @@
import { DirectedGraph } from 'graphology'
import MiniSearch from 'minisearch'
export const messageId = '__message_item'
// Reset this cache when graph changes to ensure fresh search results
export const searchCache: {
graph: DirectedGraph | null;
searchEngine: MiniSearch | null;
} = {
graph: null,
searchEngine: null
}
export const updateSearchEngine = (nodeId: string, graph: DirectedGraph) => {
if (!searchCache.searchEngine || !graph) return
const newDocument = {
id: nodeId,
label: graph.getNodeAttribute(nodeId, 'label')
}
searchCache.searchEngine.add(newDocument)
}
export const removeFromSearchEngine = (nodeId: string) => {
if (!searchCache.searchEngine) return
searchCache.searchEngine.remove({ id: nodeId })
}

View File

@@ -11,6 +11,7 @@ import { useSettingsStore } from '@/stores/settings'
import { useTabVisibility } from '@/contexts/useTabVisibility' import { useTabVisibility } from '@/contexts/useTabVisibility'
import seedrandom from 'seedrandom' import seedrandom from 'seedrandom'
import { searchCache, updateSearchEngine, removeFromSearchEngine } from '@/components/graph/graphSearchUtils'
const validateGraph = (graph: RawGraph) => { const validateGraph = (graph: RawGraph) => {
if (!graph) { if (!graph) {
@@ -544,6 +545,8 @@ const useLightrangeGraph = () => {
rawGraph.nodes.push(newNode); rawGraph.nodes.push(newNode);
// Update nodeIdMap // Update nodeIdMap
rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1; rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1;
// Update search engine with new node
updateSearchEngine(nodeId, sigmaGraph);
} }
} }
@@ -572,9 +575,13 @@ const useLightrangeGraph = () => {
} }
} }
// Update the dynamic edge map // Update the dynamic edge map and invalidate search cache
rawGraph.buildDynamicMap(); rawGraph.buildDynamicMap();
// Force search engine rebuild by invalidating cache
searchCache.graph = null;
searchCache.searchEngine = null;
// STEP 4: Update the expanded node's size // STEP 4: Update the expanded node's size
if (sigmaGraph.hasNode(nodeId)) { if (sigmaGraph.hasNode(nodeId)) {
// Get the new degree of the expanded node // Get the new degree of the expanded node
@@ -710,12 +717,18 @@ const useLightrangeGraph = () => {
// Remove from nodeIdMap // Remove from nodeIdMap
delete rawGraph.nodeIdMap[nodeToDelete]; delete rawGraph.nodeIdMap[nodeToDelete];
// Remove from search engine
removeFromSearchEngine(nodeToDelete);
} }
} }
// Rebuild the dynamic edge map // Rebuild the dynamic edge map and invalidate search cache
rawGraph.buildDynamicMap(); rawGraph.buildDynamicMap();
// Force search engine rebuild by invalidating cache
searchCache.graph = null;
searchCache.searchEngine = null;
// Show notification if we deleted more than just the selected node // Show notification if we deleted more than just the selected node
if (nodesToDelete.size > 1) { if (nodesToDelete.size > 1) {
toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size })); toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size }));