mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-11-09 21:18:58 +01:00
feat(web): Refactor icon filtering and sorting (#1288)
* feat(web): Refactor icon filtering and sorting logic using a new utility function * feat(command-menu): Improve display and performance of cmd+k menu * fix(utils): Adjust scoring logic in fuzzySearch and filter thresholds
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { IconWithName } from "@/types/icons"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -84,46 +85,153 @@ export function containsCharsInOrder(str: string, query: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced fuzzy search with multiple scoring methods
|
||||
* Returns a score from 0-1, where 1 is a perfect match
|
||||
* Advanced fuzzy search with composite scoring and bonuses:
|
||||
* - Bonus for exact, prefix, substring matches (additive)
|
||||
* - Penalize weak matches
|
||||
* - Require all query words to be present somewhere for multi-word queries
|
||||
* - Returns composite score (0-1+)
|
||||
*/
|
||||
export function fuzzySearch(text: string, query: string): number {
|
||||
if (!query) return 1
|
||||
if (!text) return 0
|
||||
|
||||
// Direct inclusion check (highest priority)
|
||||
const normalizedText = text.toLowerCase()
|
||||
const normalizedQuery = query.toLowerCase()
|
||||
|
||||
if (normalizedText === normalizedQuery) return 1
|
||||
if (normalizedText.includes(normalizedQuery)) return 0.9
|
||||
let score = 0
|
||||
|
||||
// Check for character sequence matches
|
||||
// Bonuses for strong matches
|
||||
if (normalizedText === normalizedQuery) score += 1.0
|
||||
else if (normalizedText.startsWith(normalizedQuery)) score += 0.85
|
||||
else if (normalizedText.includes(normalizedQuery)) score += 0.7
|
||||
|
||||
// Sequence, similarity, word match
|
||||
const sequenceScore = containsCharsInOrder(normalizedText, normalizedQuery)
|
||||
|
||||
// Calculate string similarity
|
||||
const similarityScore = calculateStringSimilarity(normalizedText, normalizedQuery)
|
||||
|
||||
// Word-by-word matching for multi-word queries
|
||||
// Multi-word query: require all words to be present somewhere
|
||||
const textWords = normalizedText.split(/\s+/)
|
||||
const queryWords = normalizedQuery.split(/\s+/)
|
||||
|
||||
let wordMatchCount = 0
|
||||
for (const queryWord of queryWords) {
|
||||
for (const textWord of textWords) {
|
||||
if (
|
||||
textWord === queryWord ||
|
||||
textWord.startsWith(queryWord) ||
|
||||
textWord.includes(queryWord) ||
|
||||
calculateStringSimilarity(textWord, queryWord) > 0.7 ||
|
||||
containsCharsInOrder(textWord, queryWord) > 0
|
||||
calculateStringSimilarity(textWord, queryWord) > 0.8 ||
|
||||
containsCharsInOrder(textWord, queryWord) > 0.5
|
||||
) {
|
||||
wordMatchCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allWordsPresent = wordMatchCount === queryWords.length
|
||||
const wordMatchScore = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0
|
||||
|
||||
// Combine scores with weights
|
||||
return Math.max(sequenceScore * 0.3, similarityScore * 0.3, wordMatchScore * 0.4)
|
||||
// Composite score
|
||||
score += sequenceScore * 0.1 + similarityScore * 0.1 + wordMatchScore * 0.6
|
||||
|
||||
// Penalize if not all words present in multi-word query
|
||||
if (queryWords.length > 1 && !allWordsPresent) score *= 0.4
|
||||
|
||||
// Penalize very weak matches
|
||||
if (score < 0.5) score *= 0.3
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and sort icons using advanced fuzzy search, categories, and sort options
|
||||
* - Tunable weights for name, alias, category
|
||||
* - Penalize if only category matches
|
||||
* - Require all query words to be present in at least one field
|
||||
*/
|
||||
export type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
|
||||
|
||||
export function filterAndSortIcons({
|
||||
icons,
|
||||
query = "",
|
||||
categories = [],
|
||||
sort = "relevance",
|
||||
limit,
|
||||
}: {
|
||||
icons: IconWithName[]
|
||||
query?: string
|
||||
categories?: string[]
|
||||
sort?: SortOption
|
||||
limit?: number
|
||||
}): IconWithName[] {
|
||||
const NAME_WEIGHT = 2.0
|
||||
const ALIAS_WEIGHT = 1.5
|
||||
const CATEGORY_WEIGHT = 1.0
|
||||
const CATEGORY_PENALTY = 0.7 // Penalize if only category matches
|
||||
|
||||
let filtered = icons
|
||||
|
||||
// Filter by categories if any are selected
|
||||
if (categories.length > 0) {
|
||||
filtered = filtered.filter(({ data }) =>
|
||||
data.categories.some((cat) =>
|
||||
categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (query.trim()) {
|
||||
const queryWords = query.toLowerCase().split(/\s+/)
|
||||
const scored = filtered.map((icon) => {
|
||||
const nameScore = fuzzySearch(icon.name, query) * NAME_WEIGHT
|
||||
const aliasScore =
|
||||
icon.data.aliases && icon.data.aliases.length > 0
|
||||
? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * ALIAS_WEIGHT
|
||||
: 0
|
||||
const categoryScore =
|
||||
icon.data.categories && icon.data.categories.length > 0
|
||||
? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query))) * CATEGORY_WEIGHT
|
||||
: 0
|
||||
|
||||
const maxScore = Math.max(nameScore, aliasScore, categoryScore)
|
||||
|
||||
// Penalize if only category matches
|
||||
const onlyCategoryMatch =
|
||||
categoryScore > 0.7 && nameScore < 0.5 && aliasScore < 0.5
|
||||
const finalScore = onlyCategoryMatch ? maxScore * CATEGORY_PENALTY : maxScore
|
||||
|
||||
// Require all query words to be present in at least one field
|
||||
const allWordsPresent = queryWords.every((word) =>
|
||||
icon.name.toLowerCase().includes(word) ||
|
||||
icon.data.aliases.some((alias) => alias.toLowerCase().includes(word)) ||
|
||||
icon.data.categories.some((cat) => cat.toLowerCase().includes(word))
|
||||
)
|
||||
|
||||
return { icon, score: allWordsPresent ? finalScore : finalScore * 0.4 }
|
||||
})
|
||||
.filter((item) => item.score > 0.7)
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score
|
||||
return a.icon.name.localeCompare(b.icon.name)
|
||||
})
|
||||
|
||||
filtered = scored.map((item) => item.icon)
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if (sort === "alphabetical-asc") {
|
||||
filtered = filtered.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
} else if (sort === "alphabetical-desc") {
|
||||
filtered = filtered.slice().sort((a, b) => b.name.localeCompare(a.name))
|
||||
} else if (sort === "newest") {
|
||||
filtered = filtered.slice().sort((a, b) => {
|
||||
const aTime = a.data.update?.timestamp ? new Date(a.data.update.timestamp).getTime() : 0
|
||||
const bTime = b.data.update?.timestamp ? new Date(b.data.update.timestamp).getTime() : 0
|
||||
return bTime - aTime
|
||||
})
|
||||
} // else: relevance (already sorted by score)
|
||||
|
||||
if (limit && filtered.length > limit) {
|
||||
return filtered.slice(0, limit)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user