Skip to content

Commit 4852f2f

Browse files
committed
feat: improve search matching with multi-word support and relevance ranking
Enhance the search functionality to support flexible multi-word queries and better match results: Backend changes: - Add spec titles to /plots/filter API response for richer search context - Update _collect_all_images to include title field in image dicts Frontend changes: - Implement multi-word search: all query words must match but can appear anywhere in the value (not necessarily consecutive) - Add relevance scoring to prioritize exact matches, prefix matches, and substring matches over multi-word matches - Add title field to PlotImage interface for future search enhancements Examples of improved matching: - "scatter basic" now matches: scatter-basic, basic-scatter, scatter-basic-3d - "bar horiz" now matches: bar-horizontal, bar-grouped-horizontal - Results ranked by relevance: exact > starts-with > contains > multi-word Fixes the issue where searching for two short words only worked with exact consecutive matches.
1 parent eab0148 commit 4852f2f

3 files changed

Lines changed: 80 additions & 6 deletions

File tree

api/routers/plots.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ def _collect_all_images(all_specs: list) -> list[dict]:
379379
all_specs: List of Spec objects
380380
381381
Returns:
382-
List of image dicts with spec_id, library, quality, url, thumb, and html
382+
List of image dicts with spec_id, library, quality, url, thumb, html, and title
383383
"""
384384
all_images: list[dict] = []
385385
for spec_obj in all_specs:
@@ -395,6 +395,7 @@ def _collect_all_images(all_specs: list) -> list[dict]:
395395
"url": impl.preview_url,
396396
"thumb": impl.preview_thumb,
397397
"html": impl.preview_html,
398+
"title": spec_obj.title,
398399
}
399400
)
400401
return all_images

app/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface PlotImage {
77
html?: string;
88
code?: string;
99
spec_id?: string;
10+
title?: string;
1011
}
1112

1213
// Filter system types

app/src/utils/filters.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,78 @@ export function getAvailableValuesForGroup(
7474
.sort((a, b) => b[1] - a[1]);
7575
}
7676

77+
/**
78+
* Check if a value matches the search query.
79+
* Supports multi-word queries where all words must appear in the value.
80+
*
81+
* @param value - The filter value to check
82+
* @param query - The search query (already lowercased and trimmed)
83+
* @returns True if the value matches the query
84+
*
85+
* @example
86+
* matchesSearchQuery("scatter-basic", "scatter basic") // true
87+
* matchesSearchQuery("bar-grouped-horizontal", "bar horiz") // true
88+
* matchesSearchQuery("heatmap", "heat map") // true
89+
* matchesSearchQuery("scatter", "bar line") // false (not all words match)
90+
*/
91+
function matchesSearchQuery(value: string, query: string): boolean {
92+
if (!query) return true;
93+
94+
const valueLower = value.toLowerCase();
95+
96+
// Split query into individual words (by whitespace)
97+
const words = query.split(/\s+/).filter((w) => w.length > 0);
98+
99+
// All words must appear somewhere in the value
100+
return words.every((word) => valueLower.includes(word));
101+
}
102+
103+
/**
104+
* Calculate a relevance score for a search result.
105+
* Higher score = more relevant.
106+
*
107+
* @param value - The filter value
108+
* @param query - The search query (lowercased)
109+
* @returns Relevance score (higher is better)
110+
*/
111+
function calculateRelevance(value: string, query: string): number {
112+
const valueLower = value.toLowerCase();
113+
114+
// Exact match: highest score
115+
if (valueLower === query) return 1000;
116+
117+
// Starts with query: high score
118+
if (valueLower.startsWith(query)) return 500;
119+
120+
// Contains query as substring: medium score
121+
if (valueLower.includes(query)) return 250;
122+
123+
// Multi-word match: score based on how many words match at start
124+
const words = query.split(/\s+/);
125+
const matchingWordsAtStart = words.filter((word) => valueLower.startsWith(word)).length;
126+
if (matchingWordsAtStart > 0) return 100 + matchingWordsAtStart * 50;
127+
128+
// All words match somewhere: base score
129+
return 10;
130+
}
131+
77132
/**
78133
* Search across all filter categories.
79134
*
135+
* Supports multi-word queries where all words must match.
136+
* Words can appear anywhere in the value (not necessarily consecutive).
137+
* Results are sorted by relevance and then by count.
138+
*
80139
* @param filterCounts - Available filter counts
81140
* @param activeFilters - Current active filters
82141
* @param searchQuery - Search query string
83142
* @param selectedCategory - Optional category to limit search to
84-
* @returns Matching results sorted by count
143+
* @returns Matching results sorted by relevance and count
144+
*
145+
* @example
146+
* // Query: "scatter basic" will match: scatter-basic, basic-scatter, scatter-basic-3d
147+
* // Query: "bar horiz" will match: bar-horizontal, bar-grouped-horizontal
148+
* // Results are ranked by relevance (exact match > starts with > contains > multi-word)
85149
*/
86150
export function getSearchResults(
87151
filterCounts: FilterCounts | null,
@@ -92,7 +156,7 @@ export function getSearchResults(
92156
if (!filterCounts) return [];
93157

94158
const query = searchQuery.toLowerCase().trim();
95-
const results: { category: FilterCategory; value: string; count: number }[] = [];
159+
const results: Array<{ category: FilterCategory; value: string; count: number; relevance: number }> = [];
96160

97161
const categoriesToSearch = selectedCategory ? [selectedCategory] : FILTER_CATEGORIES;
98162

@@ -102,10 +166,18 @@ export function getSearchResults(
102166

103167
for (const [value, count] of Object.entries(counts)) {
104168
if (selected.includes(value)) continue;
105-
if (query && !value.toLowerCase().includes(query)) continue;
106-
results.push({ category, value, count });
169+
if (!matchesSearchQuery(value, query)) continue;
170+
171+
const relevance = calculateRelevance(value, query);
172+
results.push({ category, value, count, relevance });
107173
}
108174
}
109175

110-
return results.sort((a, b) => b.count - a.count);
176+
// Sort by relevance (descending) then by count (descending)
177+
return results.sort((a, b) => {
178+
if (b.relevance !== a.relevance) {
179+
return b.relevance - a.relevance;
180+
}
181+
return b.count - a.count;
182+
});
111183
}

0 commit comments

Comments
 (0)