From 9be8ab434469478773a3f871161016fe2b95ec0c Mon Sep 17 00:00:00 2001 From: Michael Schmidt Date: Wed, 22 May 2024 16:16:34 +0200 Subject: [PATCH] Select best-matching node based on search query (#2903) Select best matching node based on search query --- src/common/util.ts | 12 +++ .../components/PaneNodeSearchMenu.tsx | 80 +++++++++++++++---- src/renderer/helpers/nodeSearchFuncs.ts | 75 +++++++++++++++++ src/renderer/hooks/usePaneNodeSearchMenu.tsx | 3 +- 4 files changed, 152 insertions(+), 18 deletions(-) diff --git a/src/common/util.ts b/src/common/util.ts index 5dc47c6ca6..9339180bf1 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -92,6 +92,18 @@ export const lazyKeyed = ( return value; }; }; +export const cacheLast = , T>( + fn: (arg: K) => T +): ((arg: K) => T) => { + let lastArg: K | undefined; + let lastValue: T = undefined as T; + return (arg: K): T => { + if (lastArg === arg) return lastValue; + lastValue = fn(arg); + lastArg = arg; + return lastValue; + }; +}; export const debounce = (fn: () => void, delay: number): (() => void) => { let id: NodeJS.Timeout | undefined; diff --git a/src/renderer/components/PaneNodeSearchMenu.tsx b/src/renderer/components/PaneNodeSearchMenu.tsx index 3f0dff1510..5d28c43997 100644 --- a/src/renderer/components/PaneNodeSearchMenu.tsx +++ b/src/renderer/components/PaneNodeSearchMenu.tsx @@ -14,11 +14,18 @@ import { import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { VscLightbulbAutofix } from 'react-icons/vsc'; import { CategoryMap } from '../../common/CategoryMap'; -import { Category, CategoryId, NodeSchema, SchemaId } from '../../common/common-types'; -import { assertNever, groupBy, stopPropagation } from '../../common/util'; +import { + Category, + CategoryId, + FeatureId, + FeatureState, + NodeSchema, + SchemaId, +} from '../../common/common-types'; +import { assertNever, cacheLast, groupBy, stopPropagation } from '../../common/util'; import { getCategoryAccentColor } from '../helpers/accentColors'; import { interpolateColor } from '../helpers/colorTools'; -import { getMatchingNodes } from '../helpers/nodeSearchFuncs'; +import { getBestMatch, getMatchingNodes } from '../helpers/nodeSearchFuncs'; import { useThemeColor } from '../hooks/useThemeColor'; import { IconFactory } from './CustomIcons'; import { IfVisible } from './IfVisible'; @@ -188,33 +195,72 @@ const renderGroupIcon = (categories: CategoryMap, group: SchemaGroup) => { } }; +const createMatcher = ( + schemata: readonly NodeSchema[], + categories: CategoryMap, + favorites: ReadonlySet, + suggestions: ReadonlySet, + featureStates: ReadonlyMap +) => { + return cacheLast((searchQuery: string) => { + const matchingNodes = getMatchingNodes(searchQuery, schemata, categories); + const groups = groupSchemata(matchingNodes, categories, favorites, suggestions); + const flatGroups = groups.flatMap((group) => group.schemata); + + const bestMatch = getBestMatch(searchQuery, matchingNodes, categories, (schema) => { + const isFeatureEnabled = schema.features.every((f) => { + return featureStates.get(f)?.enabled ?? false; + }); + if (!isFeatureEnabled) { + // don't suggest nodes that are not available + return 0; + } + + if (favorites.has(schema.schemaId)) { + // boost favorites + return 2; + } + return 1; + }); + + return { groups, flatGroups, bestMatch }; + }); +}; + interface MenuProps { onSelect: (schema: NodeSchema) => void; schemata: readonly NodeSchema[]; favorites: ReadonlySet; categories: CategoryMap; suggestions: ReadonlySet; + featureStates: ReadonlyMap; } export const Menu = memo( - ({ onSelect, schemata, favorites, categories, suggestions }: MenuProps) => { + ({ onSelect, schemata, favorites, categories, suggestions, featureStates }: MenuProps) => { const [searchQuery, setSearchQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); - const changeSearchQuery = useCallback((query: string) => { - setSearchQuery(query); - setSelectedIndex(0); - }, []); + const matcher = useMemo( + () => createMatcher(schemata, categories, favorites, suggestions, featureStates), + [schemata, categories, favorites, suggestions, featureStates] + ); - const groups = useMemo(() => { - return groupSchemata( - getMatchingNodes(searchQuery, schemata, categories), - categories, - favorites, - suggestions - ); - }, [searchQuery, schemata, categories, favorites, suggestions]); - const flatGroups = useMemo(() => groups.flatMap((group) => group.schemata), [groups]); + const changeSearchQuery = useCallback( + (query: string) => { + setSearchQuery(query); + + let index = 0; + if (query) { + const { flatGroups, bestMatch } = matcher(query); + index = bestMatch ? flatGroups.indexOf(bestMatch) : 0; + } + setSelectedIndex(index); + }, + [matcher] + ); + + const { groups, flatGroups } = useMemo(() => matcher(searchQuery), [searchQuery, matcher]); const onClickHandler = useCallback( (schema: NodeSchema) => { diff --git a/src/renderer/helpers/nodeSearchFuncs.ts b/src/renderer/helpers/nodeSearchFuncs.ts index 2164901d27..e97ab51e29 100644 --- a/src/renderer/helpers/nodeSearchFuncs.ts +++ b/src/renderer/helpers/nodeSearchFuncs.ts @@ -38,3 +38,78 @@ export const getMatchingNodes = ( return matchingNodes; }; + +const getMax = >( + iter: Iterable, + selector: (t: T) => number +): T | undefined => { + let max: T | undefined; + let maxVal = -Infinity; + for (const item of iter) { + const val = selector(item); + if (val > maxVal) { + maxVal = val; + max = item; + } + } + return max; +}; + +export const getBestMatch = ( + searchQuery: string, + matchingSchemata: readonly NodeSchema[], + _categories: CategoryMap, + scoreMultiplier: (schema: NodeSchema) => number = () => 1 +): NodeSchema | undefined => { + // eslint-disable-next-line no-param-reassign + searchQuery = searchQuery.trim().toLowerCase(); + if (searchQuery.length <= 1) { + // there's no point in matching against super short queries + return undefined; + } + + const g2Points = 1; + const g3Points = 3; + const g4Points = 10; + + const letter = isLetter(); + const isBoundary = (s: string, index: number) => { + if (index === 0) return true; + const before = s[index - 1]; + return !letter.isMatch(before); + }; + interface Matches { + matches: number; + boundaryMatches: number; + } + const countNGramMatches = (name: string, n: number): Matches => { + let matches = 0; + let boundaryMatches = 0; + for (let i = 0; i <= searchQuery.length - n; i += 1) { + const index = name.indexOf(searchQuery.slice(i, i + n)); + if (index !== -1) { + if (isBoundary(name, index)) { + boundaryMatches += 1; + } else { + matches += 1; + } + } + } + return { matches, boundaryMatches }; + }; + + const scoreMatches = (matches: Matches, basePoints: number) => { + return matches.matches * basePoints + matches.boundaryMatches * basePoints * 3; + }; + + return getMax(matchingSchemata, (schema) => { + const name = schema.name.toLowerCase(); + + const points = + scoreMatches(countNGramMatches(name, 2), g2Points) + + scoreMatches(countNGramMatches(name, 3), g3Points) + + scoreMatches(countNGramMatches(name, 4), g4Points); + + return (points / name.length) * scoreMultiplier(schema); + }); +}; diff --git a/src/renderer/hooks/usePaneNodeSearchMenu.tsx b/src/renderer/hooks/usePaneNodeSearchMenu.tsx index 7f46f11439..d73944d2a8 100644 --- a/src/renderer/hooks/usePaneNodeSearchMenu.tsx +++ b/src/renderer/hooks/usePaneNodeSearchMenu.tsx @@ -126,7 +126,7 @@ export const usePaneNodeSearchMenu = (): UsePaneNodeSearchMenuValue => { const useConnectingFrom = useContextSelector(GlobalVolatileContext, (c) => c.useConnectingFrom); const { createNode, createConnection } = useContext(GlobalContext); const { closeContextMenu } = useContext(ContextMenuContext); - const { schemata, functionDefinitions, categories } = useContext(BackendContext); + const { schemata, functionDefinitions, categories, featureStates } = useContext(BackendContext); const { favorites } = useNodeFavorites(); @@ -238,6 +238,7 @@ export const usePaneNodeSearchMenu = (): UsePaneNodeSearchMenuValue => {