Merge pull request #418 from bui/optimize-search

Optimize search
This commit is contained in:
Bui 2022-02-27 19:21:18 +00:00 committed by GitHub
commit 1c1234b762
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 758 additions and 61 deletions

View File

@ -25,6 +25,7 @@
color: #fff; color: #fff;
padding: 1em 1em 0 1em; padding: 1em 1em 0 1em;
z-index: 1; z-index: 1;
box-sizing: border-box;
} }
#song-search-container.touch-enabled{ #song-search-container.touch-enabled{
@ -82,6 +83,8 @@
border: 0.3em black solid; border: 0.3em black solid;
position: relative; position: relative;
--course-width: min(3em, calc(7 * var(--vmin, 1vmin))); --course-width: min(3em, calc(7 * var(--vmin, 1vmin)));
content-visibility: auto;
contain-intrinsic-size: 1px 3.2em;
} }
.song-search-result::before { .song-search-result::before {
@ -110,6 +113,10 @@
width: calc(100% - (var(--course-width) + 0.4em) * 5 - 0.6em); width: calc(100% - (var(--course-width) + 0.4em) * 5 - 0.6em);
} }
.song-search-result-info .highlighted-text {
color: #faff00;
}
.song-search-result-title, .song-search-result-title,
.song-search-result-subtitle { .song-search-result-subtitle {
display: inline-block; display: inline-block;

View File

@ -1,6 +1,7 @@
var assets = { var assets = {
"js": [ "js": [
"lib/md5.min.js", "lib/md5.min.js",
"lib/fuzzysort.js",
"loadsong.js", "loadsong.js",
"parseosu.js", "parseosu.js",
"titlescreen.js", "titlescreen.js",

View File

@ -0,0 +1,636 @@
/*
fuzzysort.js https://github.com/farzher/fuzzysort
SublimeText-like Fuzzy Search
fuzzysort.single('fs', 'Fuzzy Search') // {score: -16}
fuzzysort.single('test', 'test') // {score: 0}
fuzzysort.single('doesnt exist', 'target') // null
fuzzysort.go('mr', [{file:'Monitor.cpp'}, {file:'MeshRenderer.cpp'}], {key:'file'})
// [{score:-18, obj:{file:'MeshRenderer.cpp'}}, {score:-6009, obj:{file:'Monitor.cpp'}}]
fuzzysort.go('mr', ['Monitor.cpp', 'MeshRenderer.cpp'])
// [{score: -18, target: "MeshRenderer.cpp"}, {score: -6009, target: "Monitor.cpp"}]
fuzzysort.highlight(fuzzysort.single('fs', 'Fuzzy Search'), '<b>', '</b>')
// <b>F</b>uzzy <b>S</b>earch
*/
// UMD (Universal Module Definition) for fuzzysort
;(function(root, UMD) {
if(typeof define === 'function' && define.amd) define([], UMD)
else if(typeof module === 'object' && module.exports) module.exports = UMD()
else root.fuzzysort = UMD()
})(this, function UMD() { function fuzzysortNew(instanceOptions) {
var fuzzysort = {
single: function(search, target, options) { ;if(search=='farzher')return{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6]}
if(!search) return null
if(!isObj(search)) search = fuzzysort.getPreparedSearch(search)
if(!target) return null
if(!isObj(target)) target = fuzzysort.getPrepared(target)
var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo
: instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo
: true
var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo
return algorithm(search, target, search[0])
},
go: function(search, targets, options) { ;if(search=='farzher')return[{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6],obj:targets?targets[0]:null}]
if(!search) return noResults
search = fuzzysort.prepareSearch(search)
var searchLowerCode = search[0]
var threshold = options && options.threshold || instanceOptions && instanceOptions.threshold || -9007199254740991
var limit = options && options.limit || instanceOptions && instanceOptions.limit || 9007199254740991
var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo
: instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo
: true
var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo
var resultsLen = 0; var limitedCount = 0
var targetsLen = targets.length
// This code is copy/pasted 3 times for performance reasons [options.keys, options.key, no keys]
// options.keys
if(options && options.keys) {
var scoreFn = options.scoreFn || defaultScoreFn
var keys = options.keys
var keysLen = keys.length
for(var i = targetsLen - 1; i >= 0; --i) { var obj = targets[i]
var objResults = new Array(keysLen)
for (var keyI = keysLen - 1; keyI >= 0; --keyI) {
var key = keys[keyI]
var target = getValue(obj, key)
if(!target) { objResults[keyI] = null; continue }
if(!isObj(target)) target = fuzzysort.getPrepared(target)
objResults[keyI] = algorithm(search, target, searchLowerCode)
}
objResults.obj = obj // before scoreFn so scoreFn can use it
var score = scoreFn(objResults)
if(score === null) continue
if(score < threshold) continue
objResults.score = score
if(resultsLen < limit) { q.add(objResults); ++resultsLen }
else {
++limitedCount
if(score > q.peek().score) q.replaceTop(objResults)
}
}
// options.key
} else if(options && options.key) {
var key = options.key
for(var i = targetsLen - 1; i >= 0; --i) { var obj = targets[i]
var target = getValue(obj, key)
if(!target) continue
if(!isObj(target)) target = fuzzysort.getPrepared(target)
var result = algorithm(search, target, searchLowerCode)
if(result === null) continue
if(result.score < threshold) continue
// have to clone result so duplicate targets from different obj can each reference the correct obj
result = {target:result.target, _targetLowerCodes:null, _nextBeginningIndexes:null, score:result.score, indexes:result.indexes, obj:obj} // hidden
if(resultsLen < limit) { q.add(result); ++resultsLen }
else {
++limitedCount
if(result.score > q.peek().score) q.replaceTop(result)
}
}
// no keys
} else {
for(var i = targetsLen - 1; i >= 0; --i) { var target = targets[i]
if(!target) continue
if(!isObj(target)) target = fuzzysort.getPrepared(target)
var result = algorithm(search, target, searchLowerCode)
if(result === null) continue
if(result.score < threshold) continue
if(resultsLen < limit) { q.add(result); ++resultsLen }
else {
++limitedCount
if(result.score > q.peek().score) q.replaceTop(result)
}
}
}
if(resultsLen === 0) return noResults
var results = new Array(resultsLen)
for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll()
results.total = resultsLen + limitedCount
return results
},
goAsync: function(search, targets, options) {
var canceled = false
var p = new Promise(function(resolve, reject) { ;if(search=='farzher')return resolve([{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6],obj:targets?targets[0]:null}])
if(!search) return resolve(noResults)
search = fuzzysort.prepareSearch(search)
var searchLowerCode = search[0]
var q = fastpriorityqueue()
var iCurrent = targets.length - 1
var threshold = options && options.threshold || instanceOptions && instanceOptions.threshold || -9007199254740991
var limit = options && options.limit || instanceOptions && instanceOptions.limit || 9007199254740991
var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo
: instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo
: true
var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo
var resultsLen = 0; var limitedCount = 0
function step() {
if(canceled) return reject('canceled')
var startMs = Date.now()
// This code is copy/pasted 3 times for performance reasons [options.keys, options.key, no keys]
// options.keys
if(options && options.keys) {
var scoreFn = options.scoreFn || defaultScoreFn
var keys = options.keys
var keysLen = keys.length
for(; iCurrent >= 0; --iCurrent) {
if(iCurrent%1000/*itemsPerCheck*/ === 0) {
if(Date.now() - startMs >= 10/*asyncInterval*/) {
isNode?setImmediate(step):setTimeout(step)
return
}
}
var obj = targets[iCurrent]
var objResults = new Array(keysLen)
for (var keyI = keysLen - 1; keyI >= 0; --keyI) {
var key = keys[keyI]
var target = getValue(obj, key)
if(!target) { objResults[keyI] = null; continue }
if(!isObj(target)) target = fuzzysort.getPrepared(target)
objResults[keyI] = algorithm(search, target, searchLowerCode)
}
objResults.obj = obj // before scoreFn so scoreFn can use it
var score = scoreFn(objResults)
if(score === null) continue
if(score < threshold) continue
objResults.score = score
if(resultsLen < limit) { q.add(objResults); ++resultsLen }
else {
++limitedCount
if(score > q.peek().score) q.replaceTop(objResults)
}
}
// options.key
} else if(options && options.key) {
var key = options.key
for(; iCurrent >= 0; --iCurrent) {
if(iCurrent%1000/*itemsPerCheck*/ === 0) {
if(Date.now() - startMs >= 10/*asyncInterval*/) {
isNode?setImmediate(step):setTimeout(step)
return
}
}
var obj = targets[iCurrent]
var target = getValue(obj, key)
if(!target) continue
if(!isObj(target)) target = fuzzysort.getPrepared(target)
var result = algorithm(search, target, searchLowerCode)
if(result === null) continue
if(result.score < threshold) continue
// have to clone result so duplicate targets from different obj can each reference the correct obj
result = {target:result.target, _targetLowerCodes:null, _nextBeginningIndexes:null, score:result.score, indexes:result.indexes, obj:obj} // hidden
if(resultsLen < limit) { q.add(result); ++resultsLen }
else {
++limitedCount
if(result.score > q.peek().score) q.replaceTop(result)
}
}
// no keys
} else {
for(; iCurrent >= 0; --iCurrent) {
if(iCurrent%1000/*itemsPerCheck*/ === 0) {
if(Date.now() - startMs >= 10/*asyncInterval*/) {
isNode?setImmediate(step):setTimeout(step)
return
}
}
var target = targets[iCurrent]
if(!target) continue
if(!isObj(target)) target = fuzzysort.getPrepared(target)
var result = algorithm(search, target, searchLowerCode)
if(result === null) continue
if(result.score < threshold) continue
if(resultsLen < limit) { q.add(result); ++resultsLen }
else {
++limitedCount
if(result.score > q.peek().score) q.replaceTop(result)
}
}
}
if(resultsLen === 0) return resolve(noResults)
var results = new Array(resultsLen)
for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll()
results.total = resultsLen + limitedCount
resolve(results)
}
isNode?setImmediate(step):step() //setTimeout here is too slow
})
p.cancel = function() { canceled = true }
return p
},
highlight: function(result, hOpen, hClose) {
if(typeof hOpen == 'function') return fuzzysort.highlightCallback(result, hOpen)
if(result === null) return null
if(hOpen === undefined) hOpen = '<b>'
if(hClose === undefined) hClose = '</b>'
var highlighted = ''
var matchesIndex = 0
var opened = false
var target = result.target
var targetLen = target.length
var matchesBest = result.indexes
for(var i = 0; i < targetLen; ++i) { var char = target[i]
if(matchesBest[matchesIndex] === i) {
++matchesIndex
if(!opened) { opened = true
highlighted += hOpen
}
if(matchesIndex === matchesBest.length) {
highlighted += char + hClose + target.substr(i+1)
break
}
} else {
if(opened) { opened = false
highlighted += hClose
}
}
highlighted += char
}
return highlighted
},
highlightCallback: function(result, cb) {
if(result === null) return null
var target = result.target
var targetLen = target.length
var indexes = result.indexes
var highlighted = ''
var matchI = 0
var indexesI = 0
var opened = false
var result = []
for(var i = 0; i < targetLen; ++i) { var char = target[i]
if(indexes[indexesI] === i) {
++indexesI
if(!opened) { opened = true
result.push(highlighted); highlighted = ''
}
if(indexesI === indexes.length) {
highlighted += char
result.push(cb(highlighted, matchI++)); highlighted = ''
result.push(target.substr(i+1))
break
}
} else {
if(opened) { opened = false
result.push(cb(highlighted, matchI++)); highlighted = ''
}
}
highlighted += char
}
return result
},
prepare: function(target) {
if(!target) return {target: '', _targetLowerCodes: [0/*this 0 doesn't make sense. here because an empty array causes the algorithm to deoptimize and run 50% slower!*/], _nextBeginningIndexes: null, score: null, indexes: null, obj: null} // hidden
return {target:target, _targetLowerCodes:fuzzysort.prepareLowerCodes(target), _nextBeginningIndexes:null, score:null, indexes:null, obj:null} // hidden
},
prepareSlow: function(target) {
if(!target) return {target: '', _targetLowerCodes: [0/*this 0 doesn't make sense. here because an empty array causes the algorithm to deoptimize and run 50% slower!*/], _nextBeginningIndexes: null, score: null, indexes: null, obj: null} // hidden
return {target:target, _targetLowerCodes:fuzzysort.prepareLowerCodes(target), _nextBeginningIndexes:fuzzysort.prepareNextBeginningIndexes(target), score:null, indexes:null, obj:null} // hidden
},
prepareSearch: function(search) {
if(!search) search = ''
return fuzzysort.prepareLowerCodes(search)
},
// Below this point is only internal code
// Below this point is only internal code
// Below this point is only internal code
// Below this point is only internal code
getPrepared: function(target) {
if(target.length > 999) return fuzzysort.prepare(target) // don't cache huge targets
var targetPrepared = preparedCache.get(target)
if(targetPrepared !== undefined) return targetPrepared
targetPrepared = fuzzysort.prepare(target)
preparedCache.set(target, targetPrepared)
return targetPrepared
},
getPreparedSearch: function(search) {
if(search.length > 999) return fuzzysort.prepareSearch(search) // don't cache huge searches
var searchPrepared = preparedSearchCache.get(search)
if(searchPrepared !== undefined) return searchPrepared
searchPrepared = fuzzysort.prepareSearch(search)
preparedSearchCache.set(search, searchPrepared)
return searchPrepared
},
algorithm: function(searchLowerCodes, prepared, searchLowerCode) {
var targetLowerCodes = prepared._targetLowerCodes
var searchLen = searchLowerCodes.length
var targetLen = targetLowerCodes.length
var searchI = 0 // where we at
var targetI = 0 // where you at
var typoSimpleI = 0
var matchesSimpleLen = 0
// very basic fuzzy match; to remove non-matching targets ASAP!
// walk through target. find sequential matches.
// if all chars aren't found then exit
for(;;) {
var isMatch = searchLowerCode === targetLowerCodes[targetI]
if(isMatch) {
matchesSimple[matchesSimpleLen++] = targetI
++searchI; if(searchI === searchLen) break
searchLowerCode = searchLowerCodes[typoSimpleI===0?searchI : (typoSimpleI===searchI?searchI+1 : (typoSimpleI===searchI-1?searchI-1 : searchI))]
}
++targetI; if(targetI >= targetLen) { // Failed to find searchI
// Check for typo or exit
// we go as far as possible before trying to transpose
// then we transpose backwards until we reach the beginning
for(;;) {
if(searchI <= 1) return null // not allowed to transpose first char
if(typoSimpleI === 0) { // we haven't tried to transpose yet
--searchI
var searchLowerCodeNew = searchLowerCodes[searchI]
if(searchLowerCode === searchLowerCodeNew) continue // doesn't make sense to transpose a repeat char
typoSimpleI = searchI
} else {
if(typoSimpleI === 1) return null // reached the end of the line for transposing
--typoSimpleI
searchI = typoSimpleI
searchLowerCode = searchLowerCodes[searchI + 1]
var searchLowerCodeNew = searchLowerCodes[searchI]
if(searchLowerCode === searchLowerCodeNew) continue // doesn't make sense to transpose a repeat char
}
matchesSimpleLen = searchI
targetI = matchesSimple[matchesSimpleLen - 1] + 1
break
}
}
}
var searchI = 0
var typoStrictI = 0
var successStrict = false
var matchesStrictLen = 0
var nextBeginningIndexes = prepared._nextBeginningIndexes
if(nextBeginningIndexes === null) nextBeginningIndexes = prepared._nextBeginningIndexes = fuzzysort.prepareNextBeginningIndexes(prepared.target)
var firstPossibleI = targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1]
// Our target string successfully matched all characters in sequence!
// Let's try a more advanced and strict test to improve the score
// only count it as a match if it's consecutive or a beginning character!
if(targetI !== targetLen) for(;;) {
if(targetI >= targetLen) {
// We failed to find a good spot for this search char, go back to the previous search char and force it forward
if(searchI <= 0) { // We failed to push chars forward for a better match
// transpose, starting from the beginning
++typoStrictI; if(typoStrictI > searchLen-2) break
if(searchLowerCodes[typoStrictI] === searchLowerCodes[typoStrictI+1]) continue // doesn't make sense to transpose a repeat char
targetI = firstPossibleI
continue
}
--searchI
var lastMatch = matchesStrict[--matchesStrictLen]
targetI = nextBeginningIndexes[lastMatch]
} else {
var isMatch = searchLowerCodes[typoStrictI===0?searchI : (typoStrictI===searchI?searchI+1 : (typoStrictI===searchI-1?searchI-1 : searchI))] === targetLowerCodes[targetI]
if(isMatch) {
matchesStrict[matchesStrictLen++] = targetI
++searchI; if(searchI === searchLen) { successStrict = true; break }
++targetI
} else {
targetI = nextBeginningIndexes[targetI]
}
}
}
{ // tally up the score & keep track of matches for highlighting later
if(successStrict) { var matchesBest = matchesStrict; var matchesBestLen = matchesStrictLen }
else { var matchesBest = matchesSimple; var matchesBestLen = matchesSimpleLen }
var score = 0
var lastTargetI = -1
for(var i = 0; i < searchLen; ++i) { var targetI = matchesBest[i]
// score only goes down if they're not consecutive
if(lastTargetI !== targetI - 1) score -= targetI
lastTargetI = targetI
}
if(!successStrict) {
score *= 1000
if(typoSimpleI !== 0) score += -20/*typoPenalty*/
} else {
if(typoStrictI !== 0) score += -20/*typoPenalty*/
}
score -= targetLen - searchLen
prepared.score = score
prepared.indexes = new Array(matchesBestLen); for(var i = matchesBestLen - 1; i >= 0; --i) prepared.indexes[i] = matchesBest[i]
return prepared
}
},
algorithmNoTypo: function(searchLowerCodes, prepared, searchLowerCode) {
var targetLowerCodes = prepared._targetLowerCodes
var searchLen = searchLowerCodes.length
var targetLen = targetLowerCodes.length
var searchI = 0 // where we at
var targetI = 0 // where you at
var matchesSimpleLen = 0
// very basic fuzzy match; to remove non-matching targets ASAP!
// walk through target. find sequential matches.
// if all chars aren't found then exit
for(;;) {
var isMatch = searchLowerCode === targetLowerCodes[targetI]
if(isMatch) {
matchesSimple[matchesSimpleLen++] = targetI
++searchI; if(searchI === searchLen) break
searchLowerCode = searchLowerCodes[searchI]
}
++targetI; if(targetI >= targetLen) return null // Failed to find searchI
}
var searchI = 0
var successStrict = false
var matchesStrictLen = 0
var nextBeginningIndexes = prepared._nextBeginningIndexes
if(nextBeginningIndexes === null) nextBeginningIndexes = prepared._nextBeginningIndexes = fuzzysort.prepareNextBeginningIndexes(prepared.target)
var firstPossibleI = targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1]
// Our target string successfully matched all characters in sequence!
// Let's try a more advanced and strict test to improve the score
// only count it as a match if it's consecutive or a beginning character!
if(targetI !== targetLen) for(;;) {
if(targetI >= targetLen) {
// We failed to find a good spot for this search char, go back to the previous search char and force it forward
if(searchI <= 0) break // We failed to push chars forward for a better match
--searchI
var lastMatch = matchesStrict[--matchesStrictLen]
targetI = nextBeginningIndexes[lastMatch]
} else {
var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI]
if(isMatch) {
matchesStrict[matchesStrictLen++] = targetI
++searchI; if(searchI === searchLen) { successStrict = true; break }
++targetI
} else {
targetI = nextBeginningIndexes[targetI]
}
}
}
{ // tally up the score & keep track of matches for highlighting later
if(successStrict) { var matchesBest = matchesStrict; var matchesBestLen = matchesStrictLen }
else { var matchesBest = matchesSimple; var matchesBestLen = matchesSimpleLen }
var score = 0
var lastTargetI = -1
for(var i = 0; i < searchLen; ++i) { var targetI = matchesBest[i]
// score only goes down if they're not consecutive
if(lastTargetI !== targetI - 1) score -= targetI
lastTargetI = targetI
}
if(!successStrict) score *= 1000
score -= targetLen - searchLen
prepared.score = score
prepared.indexes = new Array(matchesBestLen); for(var i = matchesBestLen - 1; i >= 0; --i) prepared.indexes[i] = matchesBest[i]
return prepared
}
},
prepareLowerCodes: function(str) {
var strLen = str.length
var lowerCodes = [] // new Array(strLen) sparse array is too slow
var lower = str.toLowerCase()
for(var i = 0; i < strLen; ++i) lowerCodes[i] = lower.charCodeAt(i)
return lowerCodes
},
prepareBeginningIndexes: function(target) {
var targetLen = target.length
var beginningIndexes = []; var beginningIndexesLen = 0
var wasUpper = false
var wasAlphanum = false
for(var i = 0; i < targetLen; ++i) {
var targetCode = target.charCodeAt(i)
var isUpper = targetCode>=65&&targetCode<=90
var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57
var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum
wasUpper = isUpper
wasAlphanum = isAlphanum
if(isBeginning) beginningIndexes[beginningIndexesLen++] = i
}
return beginningIndexes
},
prepareNextBeginningIndexes: function(target) {
var targetLen = target.length
var beginningIndexes = fuzzysort.prepareBeginningIndexes(target)
var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow
var lastIsBeginning = beginningIndexes[0]
var lastIsBeginningI = 0
for(var i = 0; i < targetLen; ++i) {
if(lastIsBeginning > i) {
nextBeginningIndexes[i] = lastIsBeginning
} else {
lastIsBeginning = beginningIndexes[++lastIsBeginningI]
nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning
}
}
return nextBeginningIndexes
},
cleanup: cleanup,
new: fuzzysortNew,
}
return fuzzysort
} // fuzzysortNew
// This stuff is outside fuzzysortNew, because it's shared with instances of fuzzysort.new()
var isNode = typeof require !== 'undefined' && typeof window === 'undefined'
var MyMap = typeof Map === 'function' ? Map : function(){var s=Object.create(null);this.get=function(k){return s[k]};this.set=function(k,val){s[k]=val;return this};this.clear=function(){s=Object.create(null)}}
var preparedCache = new MyMap()
var preparedSearchCache = new MyMap()
var noResults = []; noResults.total = 0
var matchesSimple = []; var matchesStrict = []
function cleanup() { preparedCache.clear(); preparedSearchCache.clear(); matchesSimple = []; matchesStrict = [] }
function defaultScoreFn(a) {
var max = -9007199254740991
for (var i = a.length - 1; i >= 0; --i) {
var result = a[i]; if(result === null) continue
var score = result.score
if(score > max) max = score
}
if(max === -9007199254740991) return null
return max
}
// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop]
// prop = 'key1.key2' 10ms
// prop = ['key1', 'key2'] 27ms
function getValue(obj, prop) {
var tmp = obj[prop]; if(tmp !== undefined) return tmp
var segs = prop
if(!Array.isArray(prop)) segs = prop.split('.')
var len = segs.length
var i = -1
while (obj && (++i < len)) obj = obj[segs[i]]
return obj
}
function isObj(x) { return typeof x === 'object' } // faster as a function
// Hacked version of https://github.com/lemire/FastPriorityQueue.js
var fastpriorityqueue=function(){var r=[],o=0,e={};function n(){for(var e=0,n=r[e],c=1;c<o;){var f=c+1;e=c,f<o&&r[f].score<r[c].score&&(e=f),r[e-1>>1]=r[e],c=1+(e<<1)}for(var a=e-1>>1;e>0&&n.score<r[a].score;a=(e=a)-1>>1)r[e]=r[a];r[e]=n}return e.add=function(e){var n=o;r[o++]=e;for(var c=n-1>>1;n>0&&e.score<r[c].score;c=(n=c)-1>>1)r[n]=r[c];r[n]=e},e.poll=function(){if(0!==o){var e=r[0];return r[0]=r[--o],n(),e}},e.peek=function(e){if(0!==o)return r[0]},e.replaceTop=function(o){r[0]=o,n()},e};
var q = fastpriorityqueue() // reuse this, except for async, it needs to make its own
return fuzzysortNew()
}) // UMD
// TODO: (performance) wasm version!?
// TODO: (performance) threads?
// TODO: (performance) avoid cache misses
// TODO: (performance) preparedCache is a memory leak
// TODO: (like sublime) backslash === forwardslash
// TODO: (like sublime) spaces: "a b" should do 2 searches 1 for a and 1 for b
// TODO: (scoring) garbage in targets that allows most searches to strict match need a penality
// TODO: (performance) idk if allowTypo is optimized

View File

@ -91,20 +91,27 @@ class SongSelect{
} }
this.songSkin["default"].sort = songSkinLength + 1 this.songSkin["default"].sort = songSkinLength + 1
this.searchStyle = document.createElement("style")
var searchCss = []
Object.keys(this.songSkin).forEach(key => { Object.keys(this.songSkin).forEach(key => {
var skin = this.songSkin[key] var skin = this.songSkin[key]
var stripped = key.replace(/\W/g, '') var stripped = key.replace(/\W/g, '')
document.styleSheets[0].insertRule('.song-search-' + stripped + ' { background-color: ' + skin.background + ' }') searchCss.push('.song-search-' + stripped + ' { background-color: ' + skin.background + ' }')
document.styleSheets[0].insertRule('.song-search-' + stripped + '::before { border: 0.4em solid ' + skin.border[0] + ' ; border-bottom-color: ' + skin.border[1] + ' ; border-right-color: ' + skin.border[1] + ' }') searchCss.push('.song-search-' + stripped + '::before { border: 0.4em solid ' + skin.border[0] + ' ; border-bottom-color: ' + skin.border[1] + ' ; border-right-color: ' + skin.border[1] + ' }')
document.styleSheets[0].insertRule('.song-search-' + stripped + ' .song-search-result-title::before { -webkit-text-stroke: 0.4em ' + skin.outline + ' }') searchCss.push('.song-search-' + stripped + ' .song-search-result-title::before { -webkit-text-stroke: 0.4em ' + skin.outline + ' }')
document.styleSheets[0].insertRule('.song-search-' + stripped + ' .song-search-result-subtitle::before { -webkit-text-stroke: 0.4em ' + skin.outline + ' }') searchCss.push('.song-search-' + stripped + ' .song-search-result-subtitle::before { -webkit-text-stroke: 0.4em ' + skin.outline + ' }')
}) })
this.searchStyle.appendChild(document.createTextNode(searchCss.join("\n")))
loader.screen.appendChild(this.searchStyle)
this.font = strings.font this.font = strings.font
this.songs = [] this.songs = []
for(let song of assets.songs){ for(let song of assets.songs){
var title = this.getLocalTitle(song.title, song.title_lang)
song.titlePrepared = fuzzysort.prepare(title)
song.subtitlePrepared = fuzzysort.prepare(this.getLocalTitle(title === song.title ? song.subtitle : "", song.subtitle_lang))
this.songs.push(this.addSong(song)) this.songs.push(this.addSong(song))
} }
this.songs.sort((a, b) => { this.songs.sort((a, b) => {
@ -1081,6 +1088,10 @@ class SongSelect{
} }
this.selectableText = "" this.selectableText = ""
if(this.search && this.searchContainer){
this.searchInput()
}
}else if(!document.hasFocus() && !p2.session){ }else if(!document.hasFocus() && !p2.session){
if(this.state.focused){ if(this.state.focused){
this.state.focused = false this.state.focused = false
@ -2694,7 +2705,8 @@ class SongSelect{
return addedSong return addedSong
} }
createSearchResult(song, resultsDiv, resultWidth){ createSearchResult(result, resultWidth, fontSize){
var song = result.obj
var title = this.getLocalTitle(song.title, song.title_lang) var title = this.getLocalTitle(song.title, song.title_lang)
var subtitle = this.getLocalTitle(title === song.title ? song.subtitle : "", song.subtitle_lang) var subtitle = this.getLocalTitle(title === song.title ? song.subtitle : "", song.subtitle_lang)
@ -2712,14 +2724,20 @@ class SongSelect{
resultInfoDiv.classList.add("song-search-result-info") resultInfoDiv.classList.add("song-search-result-info")
var resultInfoTitle = document.createElement("span") var resultInfoTitle = document.createElement("span")
resultInfoTitle.classList.add("song-search-result-title") resultInfoTitle.classList.add("song-search-result-title")
this.setAltText(resultInfoTitle, title)
resultInfoTitle.appendChild(this.highlightResult(title, result[0]))
resultInfoTitle.setAttribute("alt", title)
resultInfoDiv.appendChild(resultInfoTitle) resultInfoDiv.appendChild(resultInfoTitle)
if(subtitle){ if(subtitle){
resultInfoDiv.appendChild(document.createElement("br")) resultInfoDiv.appendChild(document.createElement("br"))
var resultInfoSubtitle = document.createElement("span") var resultInfoSubtitle = document.createElement("span")
resultInfoSubtitle.classList.add("song-search-result-subtitle") resultInfoSubtitle.classList.add("song-search-result-subtitle")
this.setAltText(resultInfoSubtitle, subtitle)
resultInfoSubtitle.appendChild(this.highlightResult(subtitle, result[1]))
resultInfoSubtitle.setAttribute("alt", subtitle)
resultInfoDiv.appendChild(resultInfoSubtitle) resultInfoDiv.appendChild(resultInfoSubtitle)
} }
@ -2751,29 +2769,52 @@ class SongSelect{
resultDiv.appendChild(courseDiv) resultDiv.appendChild(courseDiv)
}) })
resultsDiv.appendChild(resultDiv) this.ctx.font = (1.2 * fontSize) + "px " + strings.font
var titleWidth = this.ctx.measureText(title).width
if(typeof resultWidth === "undefined"){ var titleRatio = resultWidth / titleWidth
var computedStyle = getComputedStyle(resultInfoDiv)
var padding = parseFloat(computedStyle.paddingLeft.slice(0, -2)) + parseFloat(computedStyle.paddingRight.slice(0, -2))
resultWidth = resultInfoDiv.offsetWidth - padding
}
var titleRatio = resultWidth / resultInfoTitle.offsetWidth
if(titleRatio < 1){ if(titleRatio < 1){
resultInfoTitle.style.transform = "scale(" + titleRatio + ", 1)" resultInfoTitle.style.transform = "scale(" + titleRatio + ", 1)"
} }
if(subtitle){ if(subtitle){
var subtitleRatio = resultWidth / resultInfoSubtitle.offsetWidth this.ctx.font = (0.8 * 1.2 * fontSize) + "px " + strings.font
var subtitleWidth = this.ctx.measureText(subtitle).width
var subtitleRatio = resultWidth / subtitleWidth
if(subtitleRatio < 1){ if(subtitleRatio < 1){
resultInfoSubtitle.style.transform = "scale(" + subtitleRatio + ", 1)" resultInfoSubtitle.style.transform = "scale(" + subtitleRatio + ", 1)"
} }
} }
return { return resultDiv
div: resultDiv, }
width: resultWidth
highlightResult(text, result){
var fragment = document.createDocumentFragment()
var indexes = result ? result.indexes : []
var ranges = []
var range
indexes.forEach(idx => {
if(range && range[1] === idx - 1){
range[1] = idx
}else{
range = [idx, idx]
ranges.push(range)
}
})
var lastIdx = 0
ranges.forEach(range => {
if(lastIdx !== range[0]){
fragment.appendChild(document.createTextNode(text.slice(lastIdx, range[0])))
}
var span = document.createElement("span")
span.classList.add("highlighted-text")
span.innerText = text.slice(range[0], range[1] + 1)
fragment.appendChild(span)
lastIdx = range[1] + 1
})
if(text.length !== lastIdx){
fragment.appendChild(document.createTextNode(text.slice(lastIdx)))
} }
return fragment
} }
searchSetActive(idx){ searchSetActive(idx){
@ -2892,9 +2933,12 @@ class SongSelect{
parseRange(string){ parseRange(string){
var range = string.split("-") var range = string.split("-")
if(range.length == 1){ if(range.length == 1){
return {min: parseInt(range[0]), max: parseInt(range[0])} var min = parseInt(range[0]) || 0
return min > 0 ? {min: min, max: min} : false
} else if(range.length == 2){ } else if(range.length == 2){
return {min: parseInt(range[0]), max: parseInt(range[1])} var min = parseInt(range[0]) || 0
var max = parseInt(range[1]) || 0
return min > 0 && max > 0 ? {min: min, max: max} : false
} }
} }
@ -2914,10 +2958,12 @@ class SongSelect{
case "hard": case "hard":
case "oni": case "oni":
case "ura": case "ura":
filters[parts[0]] = this.parseRange(parts[1]) var range = this.parseRange(parts[1])
if (range) { filters[parts[0]] = range }
break break
case "extreme": case "extreme":
filters.oni = this.parseRange(parts[1]) var range = this.parseRange(parts[1])
if (range) { filters.oni = this.parseRange(parts[1]) }
break break
case "clear": case "clear":
case "silver": case "silver":
@ -2938,22 +2984,9 @@ class SongSelect{
query = editedSplit.join(" ").trim() query = editedSplit.join(" ").trim()
var songs = assets.songs var totalFilters = Object.keys(filters).length
// TODO: fix this so it doesn't suck for(var i = 0; i < assets.songs.length; i++){
songs.sort((a, b) => { var song = assets.songs[i]
var aScore = 0
var bScore = 0
var aTitle = a.title.replace(query, "").length
var bTitle = b.title.replace(query, "").length
var aLength = aTitle - query.length
var bLength = bTitle - query.length
aScore += aLength - bLength
bScore += bLength - aLength
return aScore - bScore
})
assets.songs.forEach(song => {
var passedFilters = 0 var passedFilters = 0
Object.keys(filters).forEach(filter => { Object.keys(filters).forEach(filter => {
@ -3018,22 +3051,28 @@ class SongSelect{
} }
}) })
if(passedFilters === Object.keys(filters).length){ if(passedFilters === totalFilters){
var title = this.getLocalTitle(song.title, song.title_lang) results.push(song)
var subtitle = this.getLocalTitle(title === song.title ? song.subtitle : "", song.subtitle_lang)
if(title.toLowerCase().includes(query) || (subtitle && subtitle.toLowerCase().includes(query))){
results.push(song)
}
} }
}) }
if(query){
results = fuzzysort.go(query, results, {
keys: ["titlePrepared", "subtitlePrepared"],
allowTypo: true,
limit: 100
})
}else{
results = results.map(result => {
return {obj: result}
}).slice(0, 100)
}
results = results.slice(0, 50)
return results return results
} }
searchInput(e){ searchInput(){
var text = e.target.value.toLowerCase() var text = this.search.input.value.toLowerCase()
localStorage.setItem("lastSearchQuery", text) localStorage.setItem("lastSearchQuery", text)
if(text.length === 0){ if(text.length === 0){
@ -3051,15 +3090,27 @@ class SongSelect{
delete this.search.tip delete this.search.tip
} }
var resultsDiv = this.search.div.querySelector("#song-search-results") var resultsDiv = this.search.div.querySelector(":scope #song-search-results")
resultsDiv.innerHTML = "" resultsDiv.innerHTML = ""
this.search.results = [] this.search.results = []
var resultWidth
new_results.forEach(song => { var fontSize = parseFloat(getComputedStyle(this.search.div.querySelector(":scope #song-search")).fontSize.slice(0, -2))
var result = this.createSearchResult(song, resultsDiv, resultWidth) var resultsWidth = parseFloat(getComputedStyle(resultsDiv).width.slice(0, -2))
resultWidth = result.width var vmin = Math.min(innerWidth, lastHeight) / 100
this.search.results.push(result.div) var courseWidth = Math.min(3 * fontSize * 1.2, 7 * vmin)
var resultWidth = resultsWidth - 1.8 * fontSize - 0.8 * fontSize - (courseWidth + 0.4 * fontSize * 1.2) * 5 - 0.6 * fontSize
this.ctx.save()
var fragment = document.createDocumentFragment()
new_results.forEach(result => {
var result = this.createSearchResult(result, resultWidth, fontSize)
fragment.appendChild(result)
this.search.results.push(result)
}) })
resultsDiv.appendChild(fragment)
this.ctx.restore()
} }
searchClick(e){ searchClick(e){
@ -3258,9 +3309,11 @@ class SongSelect{
pageEvents.remove(this.touchFullBtn, "click") pageEvents.remove(this.touchFullBtn, "click")
delete this.touchFullBtn delete this.touchFullBtn
} }
loader.screen.removeChild(this.searchStyle)
delete this.selectable delete this.selectable
delete this.ctx delete this.ctx
delete this.canvas delete this.canvas
delete this.searchContainer delete this.searchContainer
delete this.searchStyle
} }
} }

View File

@ -1354,7 +1354,7 @@ var translations = {
ja: [ ja: [
"CTRL+Fで検索窓を開く!", "CTRL+Fで検索窓を開く!",
"検索フィルタの組み合わせは自由自在です!", "検索フィルタの組み合わせは自由自在です!",
"最も関連性の高い50件のみを表示します。", "最も関連性の高い100件のみを表示します。",
"キーワードでジャンルを絞り込めます!(例: \"genre:variety\", \"genre:namco\")", "キーワードでジャンルを絞り込めます!(例: \"genre:variety\", \"genre:namco\")",
"「oni:10」などのフィルターを使用して、特定の難易度の曲を検索して", "「oni:10」などのフィルターを使用して、特定の難易度の曲を検索して",
"Difficulty filters support ranges, too! Try \"ura:1-5\"!", "Difficulty filters support ranges, too! Try \"ura:1-5\"!",
@ -1367,7 +1367,7 @@ var translations = {
en: [ en: [
"Open the search window by pressing CTRL+F!", "Open the search window by pressing CTRL+F!",
"Mix and match as many search filters as you want!", "Mix and match as many search filters as you want!",
"Only the 50 most relevant search results are shown.", "Only the 100 most relevant search results are shown.",
"Filter by genre by using the \"genre:\" keyword! (e.g. \"genre:variety\", \"genre:namco\")", "Filter by genre by using the \"genre:\" keyword! (e.g. \"genre:variety\", \"genre:namco\")",
"Use filters like \"oni:10\" to search for songs with a particular difficulty!", "Use filters like \"oni:10\" to search for songs with a particular difficulty!",
"Difficulty filters support ranges, too! Try \"ura:1-5\"!", "Difficulty filters support ranges, too! Try \"ura:1-5\"!",