Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Faster searching with Fuse.js #38

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"date-fns": "^3.6.0",
"fuse.js": "7.0.0",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"match-sorter": "^6.3.1",
"node-gzip": "^1.1.2",
"polished": "^4.2.2",
"react": "^18.2.0",
Expand Down
17 changes: 11 additions & 6 deletions src/js/listing/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { GlobalStyle } from "styles/global_style";
import { IndexStyles } from "styles/index_style";
import { theme } from "theme";
import { differenceInDays } from "date-fns";
import React from "react";

const BtnSpan = styled.span`
padding: 0.4rem 0.5rem;
Expand Down Expand Up @@ -249,12 +250,16 @@ export function App(props: { scriptsFile: ScriptsFile }): JSX.Element {
const baseThree = scripts.filter((s) => BaseThree.includes(s.pk));
baseThree.sort((s1, s2) => s1.pk - s2.pk);

const custom = scripts.filter((s) => {
if (BaseThree.includes(s.pk)) {
return false;
}
return authenticated || !s.allAmne;
});
const custom = React.useMemo(
() =>
scripts.filter((s) => {
if (BaseThree.includes(s.pk)) {
return false;
}
return authenticated || !s.allAmne;
}),
[scripts, authenticated],
);

function removePrefix(s: string, prefix: string): string {
if (s.startsWith(prefix)) {
Expand Down
118 changes: 85 additions & 33 deletions src/js/listing/search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import Fuse from "fuse.js/basic";
import type { DebouncedFunc } from "lodash";
import debounce from "lodash.debounce";
import React from "react";
import { nameToId, roles } from "../botc/roles";
import { ScriptData } from "../botc/script";
import { matchSorter } from "match-sorter";

const FAVORITE_TITLES: Set<string> = new Set([
"Reptiles II: Lizard in the City",
Expand All @@ -25,43 +28,92 @@ function characterList(script: ScriptData): string[] {
return characters;
}

// match scripts that have a list of characters
function characterQueryMatches(
characters: string,
/**
* Fuzzily search through scripts by title, author, and characters
*
* @param scripts A corpus of scripts to search through
* @param query A user-provided search query
* @returns A Map from primary key to script, sorted with most relevant results first
*/
export function useQueryMatches(
scripts: ScriptData[],
): ScriptData[] {
const terms = characters.split(" ");
query: string,
): Map<number, ScriptData> {
const favorite_scripts = React.useMemo(
() =>
new Map(
scripts
.filter((s) => FAVORITE_TITLES.has(s.title))
.map((s) => [s.pk, s]),
),
[scripts],
);

return terms.reduceRight(
(results, char) =>
matchSorter(results, char.replace("-", ""), {
keys: [characterList],
threshold: matchSorter.rankings.WORD_STARTS_WITH,
const title_searcher = React.useMemo(
() =>
new Fuse(scripts, {
keys: [
{
name: "title",
getFn: (script) => script.title.replace(/[^A-Za-z]/g, ""),
},
{ name: "author" },
],
}),
scripts,
[scripts],
);
}

export function queryMatches(
scripts: ScriptData[],
query: string,
): ScriptData[] {
let matches: ScriptData[];
if (query == "") {
matches = scripts.filter((s) => FAVORITE_TITLES.has(s.title));
} else {
matches = matchSorter(scripts, query, { keys: ["title", "author"] });
if (matches.length < 10) {
// fill in results with character-based search
matches.push(
...characterQueryMatches(
query,
// start with non-matching scripts
scripts.filter((s) => !matches.some((m) => m.pk == s.pk)),
),
);
}
}
const character_searcher = React.useMemo(
() =>
new Fuse(scripts, {
keys: [
{ name: "characters", getFn: (script) => characterList(script) },
],
}),
[scripts],
);

const [matches, setMatches] = React.useState(favorite_scripts);

// Wait for the user to stop typing before searching
const debounceSearchRef =
React.useRef<DebouncedFunc<(query: string) => void>>();
React.useEffect(() => {
const debounceSearch = debounce((query: string) => {
if (query === "") {
setMatches(favorite_scripts);
return;
}

const matches = new Map<number, ScriptData>();
const scripts_with_titles_or_authors = title_searcher.search(query);
for (const match_result of scripts_with_titles_or_authors) {
const script = match_result.item;
matches.set(script.pk, script);
}
if (matches.size < 10) {
// fill in results with character-based search
const scripts_with_characters = character_searcher.search(query);
for (const match_result of scripts_with_characters) {
const script = match_result.item;
matches.set(script.pk, script);
}
}

setMatches(matches);
}, 300);
debounceSearchRef.current = debounceSearch;
return () => {
debounceSearchRef.current = undefined;
debounceSearch.cancel();
};
}, [favorite_scripts, title_searcher, character_searcher]);

// Run the debounced search when the query changes
React.useEffect(() => {
debounceSearchRef.current?.(query);
}, [query]);

return matches;
}

Expand Down
20 changes: 12 additions & 8 deletions src/js/listing/search_results.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ScriptList } from "./script_list";
import { queryMatches, searchNormalize } from "./search";
import { useQueryMatches, searchNormalize } from "./search";
import { css } from "@emotion/react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ScriptData } from "botc/script";
Expand All @@ -22,9 +22,15 @@ export function SearchResults(props: {
window.location.hash = searchNormalize(newQuery);
}

const allResults = queryMatches(scripts, query);
const results = allResults.slice(0, 20);
const extraResults = allResults.slice(20);
const allResults = useQueryMatches(scripts, query);
const results = [];
for (const result of allResults.values()) {
if (results.length >= 20) {
break;
}
results.push(result);
}
const numExtraResults = allResults.size - results.length;

return (
<>
Expand Down Expand Up @@ -63,11 +69,9 @@ export function SearchResults(props: {
</>
)}
</div>
{allResults.length == 0 && <span>No results</span>}
{allResults.size === 0 && <span>No results</span>}
<ScriptList scripts={results} />
{extraResults.length > 0 && (
<span>... plus {extraResults.length} more</span>
)}
{numExtraResults > 0 && <span>... plus {numExtraResults} more</span>}
</>
);
}
20 changes: 6 additions & 14 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1101,7 +1101,7 @@
"@babel/types" "^7.4.4"
esutils "^2.0.2"

"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.8", "@babel/runtime@^7.8.4":
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.8.4":
version "7.25.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.7.tgz#7ffb53c37a8f247c8c4d335e89cdf16a2e0d0fb6"
integrity sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==
Expand Down Expand Up @@ -4613,6 +4613,11 @@ functions-have-names@^1.2.3:
resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==

[email protected]:
version "7.0.0"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==

gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
Expand Down Expand Up @@ -5636,14 +5641,6 @@ map-obj@^4.0.0:
resolved "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz"
integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==

match-sorter@^6.3.1:
version "6.3.4"
resolved "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz"
integrity sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==
dependencies:
"@babel/runtime" "^7.23.8"
remove-accents "0.5.0"

md5.js@^1.3.4:
version "1.3.5"
resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz"
Expand Down Expand Up @@ -6657,11 +6654,6 @@ regjsparser@^0.11.0:
dependencies:
jsesc "~3.0.2"

[email protected]:
version "0.5.0"
resolved "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz"
integrity sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==

require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"
Expand Down
Loading