Skip to content

Commit f23c6ae

Browse files
feat: add fuzzy search with fuse.js and spec title search
- Add fuse.js for typo-tolerant fuzzy matching (threshold: 0.3) - Enable extended search for multi-word AND queries - Search spec titles in addition to spec_id - Show spec title as tooltip in dropdown - Add "fuzzy" divider label between exact and fuzzy matches - Add specTitles mapping to API response Closes #3828 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4852f2f commit f23c6ae

12 files changed

Lines changed: 232 additions & 109 deletions

File tree

api/routers/plots.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,13 +484,17 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir
484484
counts = _calculate_contextual_counts(filtered_images, spec_id_to_tags, impl_lookup)
485485
or_counts = _calculate_or_counts(filter_groups, all_images, spec_id_to_tags, spec_lookup, impl_lookup)
486486

487+
# Build spec_id -> title mapping for search/tooltips
488+
spec_titles = {spec_id: data["spec"].title for spec_id, data in spec_lookup.items() if data["spec"].title}
489+
487490
# Build and cache response
488491
result = FilteredPlotsResponse(
489492
total=len(filtered_images),
490493
images=filtered_images,
491494
counts=counts,
492495
globalCounts=global_counts,
493496
orCounts=or_counts,
497+
specTitles=spec_titles,
494498
)
495499

496500
try:

api/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ class FilteredPlotsResponse(BaseModel):
9797
counts: dict
9898
globalCounts: dict
9999
orCounts: list[dict]
100+
specTitles: dict[str, str] = {} # Mapping spec_id -> title for search/tooltips
100101

101102

102103
class LibraryInfo(BaseModel):

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@emotion/styled": "^11.14.1",
2121
"@mui/icons-material": "^7.3.7",
2222
"@mui/material": "^7.3.7",
23+
"fuse.js": "^7.1.0",
2324
"react": "^19.2.3",
2425
"react-dom": "^19.2.3",
2526
"react-helmet-async": "^2.0.5",

app/src/components/FilterBar.tsx

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ import { useTheme } from '@mui/material/styles';
2121
import type { FilterCategory, ActiveFilters, FilterCounts } from '../types';
2222
import { FILTER_LABELS, FILTER_TOOLTIPS, FILTER_CATEGORIES } from '../types';
2323
import type { ImageSize } from '../constants';
24-
import { getAvailableValues, getAvailableValuesForGroup, getSearchResults } from '../utils';
24+
import { getAvailableValues, getAvailableValuesForGroup, getSearchResults, type SearchResult } from '../utils';
2525

2626
interface FilterBarProps {
2727
activeFilters: ActiveFilters;
2828
filterCounts: FilterCounts | null; // Contextual counts (for AND additions)
2929
orCounts: Record<string, number>[]; // Per-group counts for OR additions
30+
specTitles: Record<string, string>; // Mapping spec_id -> title for search/tooltips
3031
currentTotal: number; // Total number of filtered images
3132
displayedCount: number; // Currently displayed images
3233
randomAnimation: { index: number; phase: 'out' | 'in'; oldLabel?: string } | null;
@@ -44,6 +45,7 @@ export function FilterBar({
4445
activeFilters,
4546
filterCounts,
4647
orCounts,
48+
specTitles,
4749
currentTotal,
4850
displayedCount,
4951
randomAnimation,
@@ -208,8 +210,8 @@ export function FilterBar({
208210

209211
// Memoize search results to avoid recalculating on every render
210212
const searchResults = useMemo(
211-
() => getSearchResults(filterCounts, activeFilters, searchQuery, selectedCategory),
212-
[filterCounts, activeFilters, searchQuery, selectedCategory]
213+
() => getSearchResults(filterCounts, activeFilters, searchQuery, selectedCategory, specTitles),
214+
[filterCounts, activeFilters, searchQuery, selectedCategory, specTitles]
213215
);
214216

215217
// Track searches with no results (debounced, to discover missing specs)
@@ -699,38 +701,83 @@ export function FilterBar({
699701
]
700702
: []),
701703
...(searchResults.length > 0
702-
? searchResults.map(({ category, value, count }, idx) => (
703-
<MenuItem
704-
key={`${category}-${value}`}
705-
onClick={() => handleValueSelect(category, value)}
706-
selected={idx === highlightedIndex}
707-
sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }}
708-
>
709-
<ListItemText
710-
primary={value}
711-
secondary={!selectedCategory ? FILTER_LABELS[category] : undefined}
712-
primaryTypographyProps={{
713-
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
714-
fontSize: '0.85rem',
715-
}}
716-
secondaryTypographyProps={{
717-
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
718-
fontSize: '0.7rem',
719-
color: '#9ca3af',
720-
}}
721-
/>
722-
<Typography
723-
sx={{
724-
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
725-
fontSize: '0.75rem',
726-
color: '#9ca3af',
727-
ml: 2,
728-
}}
729-
>
730-
({count})
731-
</Typography>
732-
</MenuItem>
733-
))
704+
? (() => {
705+
// Split results into exact and fuzzy matches
706+
const exactResults = searchResults.filter((r) => r.matchType === 'exact');
707+
const fuzzyResults = searchResults.filter((r) => r.matchType === 'fuzzy');
708+
709+
const renderMenuItem = (result: SearchResult, idx: number) => {
710+
const { category, value, count } = result;
711+
const specTitle = category === 'spec' ? specTitles[value] : undefined;
712+
const menuItem = (
713+
<MenuItem
714+
key={`${category}-${value}`}
715+
onClick={() => handleValueSelect(category, value)}
716+
selected={idx === highlightedIndex}
717+
sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }}
718+
>
719+
<ListItemText
720+
primary={value}
721+
secondary={!selectedCategory ? FILTER_LABELS[category] : undefined}
722+
primaryTypographyProps={{
723+
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
724+
fontSize: '0.85rem',
725+
}}
726+
secondaryTypographyProps={{
727+
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
728+
fontSize: '0.7rem',
729+
color: '#9ca3af',
730+
}}
731+
/>
732+
<Typography
733+
sx={{
734+
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
735+
fontSize: '0.75rem',
736+
color: '#9ca3af',
737+
ml: 2,
738+
}}
739+
>
740+
({count})
741+
</Typography>
742+
</MenuItem>
743+
);
744+
return specTitle ? (
745+
<Tooltip key={`${category}-${value}`} title={specTitle} placement="right" arrow>
746+
<span>{menuItem}</span>
747+
</Tooltip>
748+
) : (
749+
menuItem
750+
);
751+
};
752+
753+
const items: React.ReactNode[] = [];
754+
// Add exact matches
755+
exactResults.forEach((result, i) => {
756+
items.push(renderMenuItem(result, i));
757+
});
758+
// Add fuzzy label/divider if there are fuzzy results
759+
if (fuzzyResults.length > 0) {
760+
items.push(
761+
<Divider key="exact-fuzzy-divider" sx={{ my: 0.5 }}>
762+
<Typography
763+
sx={{
764+
fontSize: '0.65rem',
765+
color: '#9ca3af',
766+
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
767+
px: 1,
768+
}}
769+
>
770+
fuzzy
771+
</Typography>
772+
</Divider>
773+
);
774+
}
775+
// Add fuzzy matches
776+
fuzzyResults.forEach((result, i) => {
777+
items.push(renderMenuItem(result, exactResults.length + i));
778+
});
779+
return items;
780+
})()
734781
: [
735782
<MenuItem key="no-results" disabled>
736783
<Typography

app/src/hooks/useFilterFetch.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface FilterFetchState {
2525
filterCounts: FilterCounts | null;
2626
globalCounts: FilterCounts | null;
2727
orCounts: Record<string, number>[];
28+
specTitles: Record<string, string>;
2829
allImages: PlotImage[];
2930
displayedImages: PlotImage[];
3031
hasMore: boolean;
@@ -55,6 +56,7 @@ export function useFilterFetch({
5556
const [filterCounts, setFilterCounts] = useState<FilterCounts | null>(initialState.filterCounts ?? null);
5657
const [globalCounts, setGlobalCounts] = useState<FilterCounts | null>(initialState.globalCounts ?? null);
5758
const [orCounts, setOrCounts] = useState<Record<string, number>[]>(initialState.orCounts ?? []);
59+
const [specTitles, setSpecTitles] = useState<Record<string, string>>(initialState.specTitles ?? {});
5860
const [allImages, setAllImages] = useState<PlotImage[]>(initialState.allImages ?? []);
5961
const [displayedImages, setDisplayedImages] = useState<PlotImage[]>(initialState.displayedImages ?? []);
6062
const [hasMore, setHasMore] = useState(initialState.hasMore ?? false);
@@ -97,10 +99,11 @@ export function useFilterFetch({
9799

98100
if (abortController.signal.aborted) return;
99101

100-
// Update filter counts
102+
// Update filter counts and spec titles
101103
setFilterCounts(data.counts);
102104
setGlobalCounts(data.globalCounts || data.counts);
103105
setOrCounts(data.orCounts || []);
106+
setSpecTitles(data.specTitles || {});
104107

105108
// Shuffle images randomly on each load
106109
const shuffled = shuffleArray<PlotImage>(data.images || []);
@@ -128,6 +131,7 @@ export function useFilterFetch({
128131
filterCounts,
129132
globalCounts,
130133
orCounts,
134+
specTitles,
131135
allImages,
132136
displayedImages,
133137
hasMore,

app/src/hooks/useFilterState.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface UseFilterStateReturn {
3131
filterCounts: FilterCounts | null;
3232
globalCounts: FilterCounts | null;
3333
orCounts: Record<string, number>[];
34+
specTitles: Record<string, string>;
3435
allImages: PlotImage[];
3536
displayedImages: PlotImage[];
3637
hasMore: boolean;
@@ -86,6 +87,7 @@ export function useFilterState({
8687
filterCounts,
8788
globalCounts,
8889
orCounts,
90+
specTitles,
8991
allImages,
9092
displayedImages,
9193
hasMore,
@@ -234,6 +236,7 @@ export function useFilterState({
234236
filterCounts,
235237
globalCounts,
236238
orCounts,
239+
specTitles,
237240
allImages,
238241
displayedImages,
239242
hasMore,

app/src/pages/HomePage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function HomePage() {
3030
activeFilters,
3131
filterCounts,
3232
orCounts,
33+
specTitles,
3334
allImages,
3435
displayedImages,
3536
hasMore,
@@ -167,6 +168,7 @@ export function HomePage() {
167168
activeFilters={activeFilters}
168169
filterCounts={filterCounts}
169170
orCounts={orCounts}
171+
specTitles={specTitles}
170172
currentTotal={allImages.length}
171173
displayedCount={displayedImages.length}
172174
randomAnimation={randomAnimation}

app/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface FilteredPlotsResponse {
9696
counts: FilterCounts; // Contextual counts (for AND additions)
9797
globalCounts: FilterCounts; // Global counts (for reference)
9898
orCounts: Record<string, number>[]; // Per-group counts for OR additions
99+
specTitles: Record<string, string>; // Mapping spec_id -> title for search/tooltips
99100
}
100101

101102
export interface LibraryInfo {

0 commit comments

Comments
 (0)