| import { FC, useCallback, useEffect } from 'react' |
| import { |
| EdgeById, |
| GraphSearchInputProps, |
| GraphSearchContextProviderProps |
| } from '@react-sigma/graph-search' |
| import { AsyncSearch } from '@/components/ui/AsyncSearch' |
| import { searchResultLimit } from '@/lib/constants' |
| import { useGraphStore } from '@/stores/graph' |
| import MiniSearch from 'minisearch' |
| import { useTranslation } from 'react-i18next' |
|
|
| |
| export const messageId = '__message_item' |
|
|
| |
| export interface OptionItem { |
| id: string |
| type: 'nodes' | 'edges' | 'message' |
| message?: string |
| } |
|
|
| const NodeOption = ({ id }: { id: string }) => { |
| const graph = useGraphStore.use.sigmaGraph() |
|
|
| |
| if (!graph?.hasNode(id)) { |
| return null |
| } |
|
|
| |
| const label = graph.getNodeAttribute(id, 'label') || id |
| const color = graph.getNodeAttribute(id, 'color') || '#666' |
| const size = graph.getNodeAttribute(id, 'size') || 4 |
|
|
| |
| return ( |
| <div className="flex items-center gap-2 p-2 text-sm"> |
| <div |
| className="rounded-full flex-shrink-0" |
| style={{ |
| width: Math.max(8, Math.min(size * 2, 16)), |
| height: Math.max(8, Math.min(size * 2, 16)), |
| backgroundColor: color |
| }} |
| /> |
| <span className="truncate">{label}</span> |
| </div> |
| ) |
| } |
|
|
| function OptionComponent(item: OptionItem) { |
| return ( |
| <div> |
| {item.type === 'nodes' && <NodeOption id={item.id} />} |
| {item.type === 'edges' && <EdgeById id={item.id} />} |
| {item.type === 'message' && <div>{item.message}</div>} |
| </div> |
| ) |
| } |
|
|
|
|
| |
| |
| |
| export const GraphSearchInput = ({ |
| onChange, |
| onFocus, |
| value |
| }: { |
| onChange: GraphSearchInputProps['onChange'] |
| onFocus?: GraphSearchInputProps['onFocus'] |
| value?: GraphSearchInputProps['value'] |
| }) => { |
| const { t } = useTranslation() |
| const graph = useGraphStore.use.sigmaGraph() |
| const searchEngine = useGraphStore.use.searchEngine() |
|
|
| |
| useEffect(() => { |
| if (graph) { |
| useGraphStore.getState().resetSearchEngine() |
| } |
| }, [graph]); |
|
|
| |
| useEffect(() => { |
| |
| if (!graph || graph.nodes().length === 0 || searchEngine) { |
| return |
| } |
|
|
| |
| const newSearchEngine = new MiniSearch({ |
| idField: 'id', |
| fields: ['label'], |
| searchOptions: { |
| prefix: true, |
| fuzzy: 0.2, |
| boost: { |
| label: 2 |
| } |
| } |
| }) |
|
|
| |
| const documents = graph.nodes() |
| .filter(id => graph.hasNode(id)) |
| .map((id: string) => ({ |
| id: id, |
| label: graph.getNodeAttribute(id, 'label') |
| })) |
|
|
| if (documents.length > 0) { |
| newSearchEngine.addAll(documents) |
| } |
|
|
| |
| useGraphStore.getState().setSearchEngine(newSearchEngine) |
| }, [graph, searchEngine]) |
|
|
| |
| |
| |
| const loadOptions = useCallback( |
| async (query?: string): Promise<OptionItem[]> => { |
| if (onFocus) onFocus(null) |
|
|
| |
| if (!graph || !searchEngine) { |
| return [] |
| } |
|
|
| |
| if (graph.nodes().length === 0) { |
| return [] |
| } |
|
|
| |
| if (!query) { |
| const nodeIds = graph.nodes() |
| .filter(id => graph.hasNode(id)) |
| .slice(0, searchResultLimit) |
| return nodeIds.map(id => ({ |
| id, |
| type: 'nodes' |
| })) |
| } |
|
|
| |
| let result: OptionItem[] = searchEngine.search(query) |
| .filter((r: { id: string }) => graph.hasNode(r.id)) |
| .map((r: { id: string }) => ({ |
| id: r.id, |
| type: 'nodes' |
| })) |
|
|
| |
| |
| if (result.length < 5) { |
| |
| const matchedIds = new Set(result.map(item => item.id)) |
|
|
| |
| const middleMatchResults = graph.nodes() |
| .filter(id => { |
| |
| if (matchedIds.has(id)) return false |
|
|
| |
| if (!graph.hasNode(id)) return false |
|
|
| |
| const label = graph.getNodeAttribute(id, 'label') |
| |
| return label && |
| typeof label === 'string' && |
| !label.toLowerCase().startsWith(query.toLowerCase()) && |
| label.toLowerCase().includes(query.toLowerCase()) |
| }) |
| .map(id => ({ |
| id, |
| type: 'nodes' as const |
| })) |
|
|
| |
| result = [...result, ...middleMatchResults] |
| } |
|
|
| |
| return result.length <= searchResultLimit |
| ? result |
| : [ |
| ...result.slice(0, searchResultLimit), |
| { |
| type: 'message', |
| id: messageId, |
| message: t('graphPanel.search.message', { count: result.length - searchResultLimit }) |
| } |
| ] |
| }, |
| [graph, searchEngine, onFocus, t] |
| ) |
|
|
| return ( |
| <AsyncSearch |
| className="bg-background/60 w-24 rounded-xl border-1 opacity-60 backdrop-blur-lg transition-all hover:w-fit hover:opacity-100 w-full" |
| fetcher={loadOptions} |
| renderOption={OptionComponent} |
| getOptionValue={(item) => item.id} |
| value={value && value.type !== 'message' ? value.id : null} |
| onChange={(id) => { |
| if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null) |
| }} |
| onFocus={(id) => { |
| if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null) |
| }} |
| ariaLabel={t('graphPanel.search.placeholder')} |
| placeholder={t('graphPanel.search.placeholder')} |
| noResultsMessage={t('graphPanel.search.placeholder')} |
| /> |
| ) |
| } |
|
|
| |
| |
| |
| const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({ ...props }) => { |
| return <GraphSearchInput {...props} /> |
| } |
|
|
| export default GraphSearch |
|
|