From e480f06235149fc4cfe388270970c7b58045104b Mon Sep 17 00:00:00 2001 From: phantamanta44 Date: Mon, 19 Jul 2021 14:50:51 -0500 Subject: [PATCH 1/5] Implement autocompletion for advanced atlas --- items_2.css | 79 +++++++++++++++++ items_2.html | 37 ++++---- items_2.js | 240 +++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 items_2.css diff --git a/items_2.css b/items_2.css new file mode 100644 index 0000000..db52799 --- /dev/null +++ b/items_2.css @@ -0,0 +1,79 @@ +.all { + overflow-y: scroll; +} + +#main { + padding: 2%; +} + +#search-container { + margin-bottom: 1.5%; +} + +.search-field-container { + display: inline-block; + vertical-align: top; +} + +.search-field-label { + display: block; + font-size: 12pt; + font-weight: 700; +} + +.search-field { + width: 25vw; + padding: 8px; + position: relative; + display: block; +} + +.search-field-compl { + max-height: 50vh; + position: fixed; + display: none; + background-color: #212121; + border: solid 1px #666; + overflow-y: scroll; +} + +.search-field-compl.visible { + display: block; +} + +.search-field-compl-entry { + padding: 2px 6px; + font-size: 12pt; + font-weight: normal; + cursor: pointer; +} + +.search-field-compl-entry.focused { + background-color: #424242; +} + +.search-field-error { + display: block; + color: #ff0000; +} + +#item-list { + display: flex; + flex-flow: row wrap; + justify-content: space-evenly; +} + +.item-entry { + display: none; + width: 20vw; + margin: 1vw; + vertical-align: top; +} + +.item-entry.visible { + display: inline-block; +} + +.item-entry-sort-key { + margin-left: 1.75em; +} diff --git a/items_2.html b/items_2.html index 6915b2c..a751846 100644 --- a/items_2.html +++ b/items_2.html @@ -7,11 +7,12 @@ + WynnAtlas - +
@@ -32,29 +33,25 @@ -
-
-
- -
- -
-
+
+
+
+ + +
+
-
- -
- -
-
+
+ + +
+
-
+
diff --git a/items_2.js b/items_2.js index 9f4b939..656a731 100644 --- a/items_2.js +++ b/items_2.js @@ -1,17 +1,221 @@ +function isIdentifierChar(character) { + return /[\w\d%]/i.test(character); +} + +function isIdentifierFirstChar(character) { + return /\w/i.test(character); +} + +class AutocompleteContext { + constructor(field) { + this.field = field; + this.text = field.value; + this.cursorPos = this.startIndex = this.endIndex = field.selectionEnd; + while (this.startIndex > 0 && isIdentifierChar(this.text.charAt(this.startIndex - 1))) { + --this.startIndex; + } + if (!isIdentifierFirstChar(this.text.charAt(this.startIndex))) { + this.startIndex = this.cursorPos; + return; + } + while (this.endIndex < this.text.length && isIdentifierChar(this.text.charAt(this.endIndex))) { + ++this.endIndex; + } + } + + get valid() { + return this.endIndex > this.startIndex; + } + + get complText() { + return this.text.substring(this.startIndex, this.cursorPos); + } + + insert(completion, supplant) { + this.field.setRangeText(completion, this.startIndex, supplant ? this.endIndex : this.cursorPos, 'end'); + this.field.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); + this.startIndex = this.endIndex = -1; + setTimeout(() => this.field.focus(), 5); // no idea why i need a delay here + } +} + +class AutocompleteController { + constructor(ctx, completions, exprField) { + this.ctx = ctx; + this.completions = completions; + this.exprField = exprField; + this.currentFocus = null; + + for (const completion of completions) { + const complElem = document.createElement('div'); + complElem.classList.add('search-field-compl-entry') + complElem.setAttribute('data-compl', completion); + complElem.innerText = completion; + complElem.addEventListener('mousemove', e => this.focus(complElem)); + complElem.addEventListener('mousedown', e => this.complete(completion, true)); + exprField.completions.append(complElem); + } + } + + get valid() { + return this.ctx.valid && this.completions.length > 0; + } + + focus(complElem) { + if (this.currentFocus !== null) { + this.currentFocus.classList.remove('focused'); + } + this.currentFocus = complElem; + complElem.classList.add('focused'); + complElem.scrollIntoView({ block: 'nearest' }); + } + + focusNext() { + if (this.currentFocus === null || !this.currentFocus.nextSibling) { + this.focus(this.exprField.completions.firstChild); + } else { + this.focus(this.currentFocus.nextSibling); + } + } + + focusPrev() { + if (this.currentFocus === null || !this.currentFocus.previousSibling) { + this.focus(this.exprField.completions.lastChild); + } else { + this.focus(this.currentFocus.previousSibling); + } + } + + complete(completion, supplant) { + if (completion === null) { + completion = this.currentFocus.getAttribute('data-compl'); + if (completion === null) { + return; + } + } + this.ctx.insert(completion, supplant); + this.exprField.clearAutocomplete(); + } +} + +const getQueryIdentifiers = (function() { + let identCache = null; + return function() { + if (identCache === null) { + const idents = new Set(); + for (const ident of Object.keys(itemQueryProps)) { + idents.add(ident); + } + for (const ident of Object.keys(itemQueryFuncs)) { + idents.add(ident); + } + identCache = [...idents].sort(); // might use a trie optimally, but the set is probably small enough... + } + return identCache; + }; +})(); + // represents a field containing a query expression string class ExprField { - constructor(fieldId, errorTextId, compiler) { - this.field = document.getElementById(fieldId); - this.errorText = document.getElementById(errorTextId); + constructor(key, compiler) { + this.field = document.getElementById(`search-${key}-field`); + this.completions = document.getElementById(`search-${key}-compl`); + this.errorText = document.getElementById(`search-${key}-error`); + this.prevComplText = null; + this.prevComplPos = null; + this.complCtrl = null; this.compiler = compiler; this.output = null; this.text = null; + + this.field.addEventListener('focus', e => this.scheduleAutocomplete()); + this.field.addEventListener('change', e => this.scheduleAutocomplete()); + this.field.addEventListener('keydown', e => { + if (this.complCtrl !== null && this.complCtrl.valid) { + switch (e.key) { + case 'Up': + case 'ArrowUp': + this.complCtrl.focusPrev(); + break; + case 'Down': + case 'ArrowDown': + this.complCtrl.focusNext(); + break; + case 'Tab': + this.complCtrl.complete(null, true); + break; + case 'Enter': + this.complCtrl.complete(null, false); + break; + case 'Escape': + this.clearAutocomplete(); + break; + default: + this.scheduleAutocomplete(); + return; + } + e.preventDefault(); + } else { + switch (e.key) { + case 'Spacebar': + case ' ': + if (e.ctrlKey) { + this.autocomplete(); + return; + } + break; + } + } + this.scheduleAutocomplete() + }); + this.field.addEventListener('mousedown', e => this.scheduleAutocomplete()); + this.field.addEventListener('blur', e => this.clearAutocomplete()); } - + get value() { return this.field.value; } + scheduleAutocomplete() { + setTimeout(() => { + if (this.field.value !== this.prevComplText || this.field.selectionEnd !== this.prevComplPos) { + this.prevComplText = this.field.value; + this.prevComplPos = this.field.selectionEnd; + this.autocomplete(); + } + }, 1); + } + + autocomplete() { + while (this.completions.lastChild) { + this.completions.removeChild(this.completions.lastChild); + } + + const complCtx = new AutocompleteContext(this.field); + if (!complCtx.valid) { + this.clearAutocomplete(); + return; + } + + const complText = complCtx.complText; + const completions = getQueryIdentifiers().filter(ident => ident.startsWith(complText)); + if (completions.length === 0) { + this.clearAutocomplete(); + return; + } + + this.complCtrl = new AutocompleteController(complCtx, completions, this); + this.complCtrl.focusNext(); + this.completions.classList.add('visible'); + } + + clearAutocomplete() { + this.completions.classList.remove('visible'); + this.prevComplText = this.field.value; + this.prevComplPos = this.field.selectionEnd; + this.complCtrl = null; + } + compile() { if (this.value === this.text) return false; this.text = this.value; @@ -66,12 +270,8 @@ function init_items2() { const itemEntries = []; for (let i = 0; i < ITEM_LIST_SIZE; i++) { const itemElem = document.createElement('div'); - itemElem.classList.add('box'); + itemElem.classList.add('item-entry', 'box'); itemElem.setAttribute('id', `item-entry-${i}`); - itemElem.style.display = 'none'; - itemElem.style.width = '20vw'; - itemElem.style.margin = '1vw'; - itemElem.style.verticalAlign = 'top'; itemList.append(itemElem); itemEntries.push(itemElem); } @@ -80,11 +280,11 @@ function init_items2() { const exprParser = new ExprParser(itemQueryProps, itemQueryFuncs); // the two search query input boxes - const searchFilterField = new ExprField('search-filter-field', 'search-filter-error', function(exprStr) { + const searchFilterField = new ExprField('filter', function(exprStr) { const expr = exprParser.parse(exprStr); return expr !== null ? expr : new BoolLitTerm(true); }); - const searchSortField = new ExprField('search-sort-field', 'search-sort-error', function(exprStr) { + const searchSortField = new ExprField('sort', function(exprStr) { const subExprs = exprStr.split(';').map(e => exprParser.parse(e)).filter(f => f != null); return { type: 'array', @@ -109,7 +309,7 @@ function init_items2() { // hide old search results itemListFooter.innerText = ''; - for (const itemEntry of itemEntries) itemEntry.style.display = 'none'; + for (const itemEntry of itemEntries) itemEntry.classList.remove('visible'); // index and sort search results const searchResults = []; @@ -129,7 +329,14 @@ function init_items2() { return; } try { - searchResults.sort((a, b) => compareLexico(a.item, a.sortKeys, b.item, b.sortKeys)); + searchResults.sort((a, b) => { + try { + return compareLexico(a.item, a.sortKeys, b.item, b.sortKeys); + } catch (e) { + console.log(a.item, b.item); + throw e; + } + }); } catch (e) { searchSortField.errorText.innerText = e.message; return; @@ -139,14 +346,13 @@ function init_items2() { const searchMax = Math.min(searchResults.length, ITEM_LIST_SIZE); for (let i = 0; i < searchMax; i++) { const result = searchResults[i]; - itemEntries[i].style.display = 'inline-block'; + itemEntries[i].classList.add('visible'); displayExpandedItem(result.itemExp, `item-entry-${i}`); if (result.sortKeys.length > 0) { const sortKeyListContainer = document.createElement('div'); sortKeyListContainer.classList.add('itemleft'); const sortKeyList = document.createElement('ul'); - sortKeyList.classList.add('itemp', 'T0'); - sortKeyList.style.marginLeft = '1.75em'; + sortKeyList.classList.add('item-entry-sort-key', 'itemp', 'T0'); sortKeyListContainer.append(sortKeyList); for (let j = 0; j < result.sortKeys.length; j++) { const sortKeyElem = document.createElement('li'); @@ -163,6 +369,7 @@ function init_items2() { // updates the search state from the input boxes after a brief delay, to prevent excessive DOM updates let updateSearchTask = null; + function scheduleSearchUpdate() { if (updateSearchTask !== null) { clearTimeout(updateSearchTask); @@ -172,6 +379,7 @@ function init_items2() { updateSearch(); }, 500); } + searchFilterField.field.addEventListener('input', e => scheduleSearchUpdate()); searchSortField.field.addEventListener('input', e => scheduleSearchUpdate()); From ee0d55cec9a2214e7532869204306cef2cce49f7 Mon Sep 17 00:00:00 2001 From: phantamanta44 Date: Mon, 19 Jul 2021 14:51:13 -0500 Subject: [PATCH 2/5] Fix incorrect type for numeric rarity in advanced atlas --- query_2.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/query_2.js b/query_2.js index 1c3b675..8c656cf 100644 --- a/query_2.js +++ b/query_2.js @@ -76,7 +76,7 @@ const itemQueryProps = (function() { prop(['cat', 'category'], 'string', (i, ie) => i.category); const tierIndices = { Normal: 0, Unique: 1, Set: 2, Rare: 3, Legendary: 4, Fabled: 5, Mythic: 6 }; prop(['rarityname', 'raritystr', 'tiername', 'tierstr'], 'string', (i, ie) => i.tier); - prop(['rarity', 'tier'], 'string', (i, ie) => tierIndices[i.tier]); + prop(['rarity', 'tier'], 'number', (i, ie) => tierIndices[i.tier]); prop(['level', 'lvl', 'combatlevel', 'combatlvl'], 'number', (i, ie) => i.lvl); prop(['strmin', 'strreq'], 'number', (i, ie) => i.strReq); From f90a23209259786f130b53e2c32b0d42df696dc8 Mon Sep 17 00:00:00 2001 From: phantamanta44 Date: Mon, 19 Jul 2021 14:53:01 -0500 Subject: [PATCH 3/5] Make search error text not take up space in advanced atlas --- items_2.css | 1 + items_2.html | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/items_2.css b/items_2.css index db52799..3ea83ff 100644 --- a/items_2.css +++ b/items_2.css @@ -53,6 +53,7 @@ } .search-field-error { + position: absolute; display: block; color: #ff0000; } diff --git a/items_2.html b/items_2.html index a751846..3fe2656 100644 --- a/items_2.html +++ b/items_2.html @@ -39,15 +39,15 @@ -
+
-
+
From 1a31be229d29999df87da2a51e62f727034d2f4e Mon Sep 17 00:00:00 2001 From: phantamanta44 Date: Mon, 19 Jul 2021 15:04:19 -0500 Subject: [PATCH 4/5] Add mobile styling for advanced atlas --- items_2.css | 36 ++++++++++++++++++++++++++++++++++++ items_2.html | 3 +++ 2 files changed, 39 insertions(+) diff --git a/items_2.css b/items_2.css index 3ea83ff..9bdfc91 100644 --- a/items_2.css +++ b/items_2.css @@ -35,6 +35,7 @@ background-color: #212121; border: solid 1px #666; overflow-y: scroll; + z-index: 20; } .search-field-compl.visible { @@ -56,6 +57,29 @@ position: absolute; display: block; color: #ff0000; + z-index: 10; +} + +@media(max-width: 1099px) { + #search-container { + margin-bottom: 8px; + align-items: center; + display: flex; + flex-flow: column; + } + + .search-field-container { + display: block; + margin-bottom: 16px; + } + + .search-field { + width: 75vw; + } + + .search-field-error { + font-size: 10pt; + } } #item-list { @@ -78,3 +102,15 @@ .item-entry-sort-key { margin-left: 1.75em; } + +@media(max-width: 1099px) { + #item-list { + flex-flow: column; + align-items: center; + } + + .item-entry { + width: 100%; + margin: 0 0 14px; + } +} diff --git a/items_2.html b/items_2.html index 3fe2656..f0650c1 100644 --- a/items_2.html +++ b/items_2.html @@ -2,6 +2,9 @@ + + + From d839503c1e369f2b53e4054b574e469e36992ca6 Mon Sep 17 00:00:00 2001 From: phantamanta44 Date: Mon, 19 Jul 2021 15:13:15 -0500 Subject: [PATCH 5/5] Add scroll-to-top button for mobile advanced atlas --- items_2.css | 23 +++++++++++++++++++++++ items_2.html | 1 + items_2.js | 4 ++++ 3 files changed, 28 insertions(+) diff --git a/items_2.css b/items_2.css index 9bdfc91..f23e295 100644 --- a/items_2.css +++ b/items_2.css @@ -114,3 +114,26 @@ margin: 0 0 14px; } } + +#scroll-up { + width: 10vw; + height: 10vw; + bottom: 4vw; + right: 4vw; + position: fixed; + display: none; + background-color: #212121; + font-size: 12pt; + font-weight: bold; + text-align: center; + line-height: 10vw; + border: 1px solid #666; + cursor: pointer; + user-select: none; +} + +@media(max-width: 1099px) { + #scroll-up { + display: block; + } +} diff --git a/items_2.html b/items_2.html index f0650c1..24a4c38 100644 --- a/items_2.html +++ b/items_2.html @@ -58,6 +58,7 @@
+ diff --git a/items_2.js b/items_2.js index 656a731..1f846ed 100644 --- a/items_2.js +++ b/items_2.js @@ -404,6 +404,10 @@ function init_items2() { // focus the query filter text box searchFilterField.field.focus(); searchFilterField.field.select(); + + // scroll-to-top button + document.getElementById('scroll-up') + .addEventListener('mousedown', e => scrollTo({ top: 0, behavior: 'smooth' })); } load_init(init_items2);