Skip to content

Commit f48db8d

Browse files
authored
Feature/fuzzy search v3 (#42)
* Create fuse config file * Create fuzzy and course data flatten helpers * Install fuse.js * Update fetchList to flatten courses and reinit fuse obj * Refactor reactive state system for showing courses * Implement search changes to frontend * Tuned fuse/fuzzy threshold * Implement GE filtering * Fine tune fuzz object +0.05 threshold + always sort
1 parent 48e1b52 commit f48db8d

4 files changed

Lines changed: 69 additions & 27 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"dependencies": {
1010
"@heroicons/vue": "^2.1.3",
1111
"firebase": "^10.11.0",
12+
"fuse.js": "^7.1.0",
1213
"ol": "^9.1.0",
1314
"vue": "^3.4.22",
1415
"vue-router": "^4.3.0"

src/components/Course.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,4 @@ function toggleFocus () {
128128
max-height: calc(100vh - 7rem);
129129
}
130130
}
131-
</style>
131+
</style>

src/utils/fuzz.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export const courseFuseOptions = {
2+
shouldSort: true,
3+
includeMatches: false,
4+
threshold: 0.25,
5+
keys: [
6+
"id",
7+
"name",
8+
"subject"
9+
]
10+
};
11+
// in case fuzzy needed elsewhere, put separate configs below
12+
13+
14+
export function fuzzyQuery(query, fuseObj, list) {
15+
if (!query) return list
16+
if (!fuseObj) return list
17+
18+
const normalized = query.replaceAll(' ', '').toUpperCase()
19+
20+
// Substring search first
21+
const substringMatches = list.filter(item =>
22+
item.id?.includes(normalized) || item.name?.toUpperCase().includes(normalized)
23+
)
24+
25+
if (substringMatches.length > 0) return substringMatches
26+
27+
// Corrections after using fuzzy match
28+
return fuseObj.search(query).map(r => r.item)
29+
}
30+
31+
// Flattens course data specifically for fuzzy searchability
32+
export function flattenCourseData(unflattenedCourses) {
33+
const flat = [];
34+
for (const subject in unflattenedCourses) {
35+
for (const course in unflattenedCourses[subject]) {
36+
const courseInfo = unflattenedCourses[subject][course];
37+
flat.push({ subject, id: course, name: courseInfo[0] })
38+
}
39+
}
40+
return flat;
41+
}

src/views/Course.vue

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,47 +10,47 @@ import Course from '../components/Course.vue'
1010
import Wrapper from '../components/Wrapper.vue'
1111
import PanelWrapper from '../components/PanelWrapper.vue'
1212
import LabelSwitch from '../components/LabelSwitch.vue'
13+
import Fuse from 'fuse.js'
14+
import { courseFuseOptions, flattenCourseData, fuzzyQuery } from '../utils/fuzz.js'
1315
import { useRouter, useRoute } from 'vue-router'
1416
const router = useRouter()
1517
const route = useRoute()
1618
log('web/course')
1719
18-
let loading = $ref(true), list = $ref(null), hideList = $ref({}), showSub = $ref({}), subjects = $ref([])
19-
let quarters = $ref([]), focus = $ref('')
20+
let loading = $ref(true), list = $ref(null), showCourses = $ref({})
21+
let quarters = $ref([]), focus = $ref(''), flatCourses = [], fuse = null
2022
const query = reactive({
2123
search: '', college: '', GE: {}
2224
})
2325
const showFocus = $computed(() => Object.keys(state.course.focus).filter(x => state.course.focus[x]).length)
2426
25-
function isHide (k, v, key, ges) {
26-
const genames = v[1].split(',')
27-
for (const g of ges) {
28-
if (!genames.includes(g)) return true
29-
}
30-
if (!k.includes(key) && !v[0].includes(key)) return true
31-
}
32-
3327
const computeResult = debounce(() => {
34-
hideList = {}
35-
showSub = {}
3628
const key = query.search.replaceAll(' ', '').toUpperCase()
3729
const ges = Object.keys(query.GE).filter(x => query.GE[x]).map(x => query.college + '-' + x)
38-
for (const sub in list) {
39-
let hasOne = false
40-
for (const k in list[sub]) {
41-
hideList[k] = isHide(k, list[sub][k], key, ges)
42-
if (!hideList[k]) hasOne = true
30+
const res = fuzzyQuery(key, fuse, flatCourses)
31+
showCourses = res.reduce((acc, course) => {
32+
const key = course.subject
33+
if (ges.length == 0) {
34+
if (!acc[key]) acc[key] = []
35+
acc[key].push(course)
36+
return acc
4337
}
44-
if (hasOne) showSub[sub] = 0
45-
}
46-
if (Object.keys(showSub).length === 1) showSub[Object.keys(showSub)[0]] = 1
38+
for (const ge of ges) {
39+
if (list[key][course.id][1].includes(ge)) {
40+
if (!acc[key]) acc[key] = []
41+
acc[key].push(course)
42+
}
43+
}
44+
return acc
45+
}, {})
4746
})
4847
watch(query, computeResult)
4948
5049
async function fetchList () {
5150
loading = true
5251
list = await get('cache/course.' + state.course.quarter).then(data => JSON.parse(data.data))
53-
subjects = Object.keys(list).sort()
52+
flatCourses = flattenCourseData(list)
53+
fuse = fuse ? (fuse.setCollection(flatCourses), fuse) : new Fuse(flatCourses, courseFuseOptions)
5454
computeResult()
5555
loading = false
5656
}
@@ -131,12 +131,12 @@ watch(() => route.query.quarter, v => {
131131
</div>
132132
<div class="flex items-start" v-if="!loading">
133133
<div class="w-full md:w-80 md:mr-6 shadow-md" style="min-width: 20rem;"><!-- course list -->
134-
<template v-for="sub in subjects"><!-- subject -->
135-
<PanelWrapper v-if="showSub[sub] > -1" :title="sub + ': ' + lookup.subjects[sub]" v-model="showSub[sub]">
136-
<template v-for="(v, k) in list[sub]">
134+
<template v-for="(courses, subject) in showCourses"><!-- subject -->
135+
<PanelWrapper :title="subject + ': ' + lookup.subjects[subject]">
136+
<template v-for="course in courses">
137137
<!-- course -->
138-
<div class="bg-white border p-2 cursor-pointer" v-if="!hideList[k]" @click="focus = k">
139-
<h3 class="pl-2">{{ k }}: {{ v[0] }}</h3>
138+
<div class="bg-white border p-2 cursor-pointer" @click="focus = course.id">
139+
<h3 class="pl-2">{{ course.id }}: {{ course.name }}</h3>
140140
</div>
141141
</template>
142142
</PanelWrapper>

0 commit comments

Comments
 (0)