Skip to content

Commit

Permalink
Select best-matching node based on search query (#2903)
Browse files Browse the repository at this point in the history
Select best matching node based on search query
  • Loading branch information
RunDevelopment authored May 22, 2024
1 parent 9f3e2b2 commit 9be8ab4
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 18 deletions.
12 changes: 12 additions & 0 deletions src/common/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ export const lazyKeyed = <K extends object, T extends {} | null>(
return value;
};
};
export const cacheLast = <K extends NonNullable<unknown>, 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;
Expand Down
80 changes: 63 additions & 17 deletions src/renderer/components/PaneNodeSearchMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -188,33 +195,72 @@ const renderGroupIcon = (categories: CategoryMap, group: SchemaGroup) => {
}
};

const createMatcher = (
schemata: readonly NodeSchema[],
categories: CategoryMap,
favorites: ReadonlySet<SchemaId>,
suggestions: ReadonlySet<SchemaId>,
featureStates: ReadonlyMap<FeatureId, FeatureState>
) => {
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<SchemaId>;
categories: CategoryMap;
suggestions: ReadonlySet<SchemaId>;
featureStates: ReadonlyMap<FeatureId, FeatureState>;
}

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) => {
Expand Down
75 changes: 75 additions & 0 deletions src/renderer/helpers/nodeSearchFuncs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,78 @@ export const getMatchingNodes = (

return matchingNodes;
};

const getMax = <T extends NonNullable<unknown>>(
iter: Iterable<T>,
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);
});
};
3 changes: 2 additions & 1 deletion src/renderer/hooks/usePaneNodeSearchMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -238,6 +238,7 @@ export const usePaneNodeSearchMenu = (): UsePaneNodeSearchMenuValue => {
<Menu
categories={categories}
favorites={favorites}
featureStates={featureStates}
schemata={menuSchemata}
suggestions={suggestions}
onSelect={onSchemaSelect}
Expand Down

0 comments on commit 9be8ab4

Please sign in to comment.