Widget:CollectionTrackerJS

From Granblue Fantasy Wiki
Jump to navigation Jump to search
<script type="application/javascript">
// Widget:CollectionTrackerJS
;(function() {
  'use strict';

  if (document.querySelectorAll('.tracker-wrap').length < 1)
    return;

  const fixedHash = '';
  let lastHash = '';
  let knownObtains = [];

  const addEventForChild = function(parent, eventName, childSelector, callback){
    parent.addEventListener(eventName, function(event){
      const clickedElement = event.target;
      const matchingChild = clickedElement.closest(childSelector);
      if (matchingChild)
        callback(event, matchingChild);
    });
  };
  const isNumeric = function(n) {
    return !isNaN(parseFloat(n)) && isFinite(n);
  };

  const toggleVisible = function(node, visible) {
    node.style.display = (visible ? '' : 'none');
  };

  /**
   * @return {string}
   */
  const Uint8ArrayToBase64 = function(bytes) {
    let binary = '';
    let len = bytes.byteLength;
    for (let i = 0; i < len; i++)
      binary += String.fromCharCode(bytes[i]);
    let result = window.btoa(binary);
    result = result.replace('=', ''); // Remove any trailing '='s
    result = result.replace('+', '-'); // 62nd char of encoding
    result = result.replace('/', '_'); // 63rd char of encoding
    if (result === 'AAAA')
      return '';
    return result;
  };

  const Base64ToUint8Array = function(text) {
    let s = text.replace('_', '/'); // 63rd char of encoding
    s = s.replace('-', '+'); // 62nd char of encoding
    let pl = (s.length % 4);
    if (pl > 0)
      s += '='.repeat(4-pl); // Restore any trailing '='s

    let binary = window.atob(s);
    let len = binary.length;
    let result = new Uint8Array(len);
    for (let i = 0; i < len; i++)
      result[i] = binary.charCodeAt(i);
    return result;
  };

  const replaceHash = function(newHash) {
    lastHash = newHash;
    if ((''+newHash).charAt(0) !== '#')
      newHash = '#' + newHash;
    history.replaceState('', '', newHash);
  };

  const readHash = function() {
    // format: Options.SSR Characters.SR Characters.R Characters.SSR Summons.SR Summons.R Summons
    // - Options is a base64 encoded json object
    // - Character/Summons are base64 encoded sets of bits, where each character is 3 bits offset by the counter in its ID
    lastHash = window.location.hash;
    let hash = (fixedHash) ? fixedHash : window.location.hash.substr(1);
    
    // Backward compatibility with URL using semicolon (;) as separator
    const separator = (hash.indexOf(";") > 0) ? ";" : ".";
    let parts = hash.split(separator);
    
    for (let i = 1; i <= 7; i++)
      if (typeof parts[i] !== 'string')
        parts[i] = '';
    let strings = {
      'c': {2: parts[3], 3: parts[2], 4: parts[1]},
      's': {2: parts[6], 3: parts[5], 4: parts[4]},
    };

    // Reset current options
    document.querySelectorAll('.tracker-filter-options input[type="checkbox"]').forEach(function(checkbox) { checkbox.checked = false; });
    document.querySelectorAll('.tracker-filter-group label.mw-ui-progressive').forEach(function(label) { label.classList.remove('mw-ui-progressive'); });
    document.querySelectorAll('.tracker-filter-group label[data-value="*"]').forEach(function(label) { label.classList.add('mw-ui-progressive'); });

    // load options
    let reOptions = /[g-z][0-9a-f]{1,8}/ig;
    let options = parts[0].match(reOptions);
    if (options != null) {
      for (let i = 0; i < options.length; i++) {
        let text = options[i];
        let bits = parseInt(text.substr(1), 16);
        let optionSelector = 'div[data-option="'+text.charAt(0)+'"]';

        let option = document.querySelector(optionSelector);
        if (option == null)
          continue;

        let optionBits = option.querySelectorAll('[data-bit]');
        if (optionBits.length <= 0)
          continue;

        option.querySelectorAll('label').forEach(function(label) {
          label.classList.remove('mw-ui-progressive');
        });
        optionBits.forEach(function(optionBit) {
          let selected = ((1 << optionBit.dataset.bit) & bits) > 0;
          if (optionBit.tagName.toLowerCase() === 'input') {
            optionBit.checked = selected;
          } else if (selected) {
            optionBit.classList.add('mw-ui-progressive');
          }
        });

        // verify we actually selected something
        if (option.querySelectorAll('.mw-ui-progressive').length > 0)
          continue;
        // we didn't so select All label
        let all = option.querySelector('label[data-value="*"]');
        if (all != null)
          all.classList.add('mw-ui-progressive');
      }
    }

    // reset items
    document.querySelectorAll('.tracker-item').forEach(function(item) {
      item.dataset.evo = '0';
      item.dataset.owned = 'false';
      evolve(item, false);
    });

    // load items
    ['c', 's'].forEach(function(type) {
      [2, 3, 4].forEach(function(rarity) {
        let str = strings[type][rarity];
        if(str.length < 1)
          return;
        let buffer = Base64ToUint8Array(str);
        let len = buffer.length / 3;
        for (let i = 0; i < len; i++) {
          let evos = 0;
          evos |= (buffer[i*3  ] <<  0);
          evos |= (buffer[i*3+1] <<  8);
          evos |= (buffer[i*3+2] << 16);

          for (let j = 0; j < 8; j++) {
            let evo = (evos >> (j*3)) & 0x07;
            if (evo <= 0)
              continue;
            let short_id = '' + rarity + ('000'+(i*8+j)).slice(-3);
            let itemSelector = '.tracker-item[data-type="'+type+'"][data-short_id="'+short_id+'"]';
            let items = document.querySelectorAll(itemSelector);
            items.forEach(function(item) {
              evolve(item, evo);
            });
          }
        }
      });
    });
  };

  const makeHash = function() {
    // for format see readHash
    let strings = {
      'c': {2:'',3:'',4:''},
      's': {2:'',3:'',4:''},
    };
    let selected = {
      'c': {2:{},3:{},4:{}},
      's': {2:{},3:{},4:{}},
    };

    // store which items have been selected
    document.querySelectorAll('.tracker-item.selected').forEach(function(item) {
      let short_id = item.dataset.short_id;
      if (short_id.length === 4) {
        let type = item.dataset.type;
        let rarity = parseInt(short_id[0], 10);
        if ((rarity < 2) || (rarity > 4))
          return;
        let index = parseInt(short_id.substr(1), 10);
        selected[type][rarity][index] = parseInt(item.dataset.evo,10);
      }
    });

    // convert to a bit array
    ['c', 's'].forEach(function(type) {
      [2, 3, 4].forEach(function(rarity) {
        let high_id = 0;
        let obj = selected[type][rarity];
        for (let index in obj)
          high_id = Math.max(high_id, index);

        // Group 8 items with 3 bits each
        let parts = Math.floor(high_id / 8) + 1;
        let size = parts * 3;
        let buffer = new Uint8Array(size);

        for (let i = 0; i <= Math.floor(high_id / 8); i++) {
          let evos = 0x000000;
          for (let j = 0; j < 8; j++) {
            let evo = obj[i*8+j];
            if (evo == undefined)
              evo = 0;
            evos |= (evo << (j*3));
          }
          buffer[i*3]   = (evos >>  0) & 0xFF;
          buffer[i*3+1] = (evos >>  8) & 0xFF;
          buffer[i*3+2] = (evos >> 16) & 0xFF;
        }
        strings[type][rarity] += Uint8ArrayToBase64(buffer);
      });
    });

    // Options are stored as hex encoded bit array
    let options = '';
    document.querySelectorAll('div[data-option]').forEach(function(div) {
      let active = div.querySelectorAll('[data-bit].mw-ui-progressive, input[data-bit]:checked');
      if (active.length > 0) {
        let option = 0;
        active.forEach(function(bit) {
          option |= 1 << bit.dataset.bit;
        });
        options += div.dataset.option + option.toString(16);
      }
    });

    let hash = "";
    hash += `${options}.`;
    hash += `${strings.c[4]}.`;
    hash += `${strings.c[3]}.`;
    hash += `${strings.c[2]}.`;
    hash += `${strings.s[4]}.`;
    hash += `${strings.s[3]}.`;
    hash += `${strings.s[2]}`;
    replaceHash(hash);
  };

  const evolve = function(node, levels) {
    let toggle = node.parentElement.classList.contains('tracker-hide-uncap');
    let evo = parseInt(node.dataset.evo, 10);
    let evoMax = parseInt(node.dataset.evoMax, 10);
    if (levels === false) {
      evo = 0;
    } else if (toggle) {
      evo = evo > 0 ? 0 : 1;
    } else if (levels === true) {
      evo += levels;
      if (evo > Math.max(evoMax+1, 1))
        evo = 0;
    } else {
      evo = levels;
    }
    node.dataset.evo = evo.toString(10);
    node.dataset.owned = evo > 0 ? 'true' : 'false';
    node.classList.toggle('selected', evo > 0);
    evo -= 1;
    node.querySelectorAll('.tracker-uncap-star').forEach(function(star) {
      star.classList.toggle('selected', evo > 0);
      evo -= 1;
    });
  };

  const updateUncap = function() {
    let cb = document.querySelector('#tracker-character-uncap');
    document.querySelectorAll('.tracker-box[id$="-characters"]').forEach(function(box) {
      box.classList.toggle('tracker-hide-uncap', !cb.checked);
    });
    cb = document.querySelector('#tracker-summon-uncap');
    document.querySelectorAll('.tracker-box[id$="-summons"]').forEach(function(box) {
      box.classList.toggle('tracker-hide-uncap', !cb.checked);
    });
    makeHash();
  };

  const moveItems = function(type, element, rarity) {
    let divSelector = '#tracker-'+element+'-' + (type==='c'?'characters':'summons');
    let tracker = document.querySelector(divSelector);
    if (tracker == null)
      return;
    let selector = '.tracker-item[data-type="'+type+'"][data-element="'+element+'"][data-rarity="'+rarity+'"]';
    document.querySelectorAll(selector).forEach(function(node) {
      node.dataset.owned = 'false';
      node.dataset.evo = '0';
      if (isNumeric(node.dataset.baseevo) && isNumeric(node.dataset.maxevo)) {
        node.dataset.evoBase = parseInt(node.dataset.baseevo, 10).toString(10);
        node.dataset.evoMax = parseInt(node.dataset.maxevo, 10).toString(10);
      } else {
        node.dataset.evoBase = '0';
        node.dataset.evoMax = '0';
      }
      node.parentElement.removeChild(node);
      tracker.insertBefore(node, null);

      let evoBase = parseInt(node.dataset.evoBase);
      let evoMax  = parseInt(node.dataset.evoMax);

      let uncapBrown = '<div class="tracker-uncap-star tracker-uncap-base"></div>'.repeat(evoBase);
      let uncapBlue  = '<div class="tracker-uncap-star tracker-uncap-max"></div>'.repeat(evoMax-evoBase);
      let uncap = `<div class="tracker-uncap">${uncapBrown}${uncapBlue}</div>`;
      node.insertAdjacentHTML('beforeend', uncap);
    });
  };

  const getFilter = function(name) {
    let result = [];
    let selector = '#tracker-filter-'+name+' label.mw-ui-progressive';
    let filters = document.querySelectorAll(selector);
    for (let i = 0; i < filters.length; i++) {
      let filter = filters[i];
      if (filter.dataset.value === '*')
        return true;
      let values = filter.dataset.value.split(';');
      values.forEach(function(value) {
        result.push(value);
      });
    }
    return result;
  };

  const updateItems = function() {
    console.time('updating');
    let rarity = getFilter('rarity');
    let type = getFilter('type');
    let element = getFilter('element');
    let obtain = getFilter('obtain');
    let style = getFilter('style');
    let race = getFilter('race');
    let gender = getFilter('gender');
    let maxevo = getFilter('maxevo');
    let owned = getFilter('owned');
    let weapon = getFilter('weapon');
    let released = getFilter('released');
    let search = document.querySelector('#tracker-search').value;
    let reSearch;
    try {
      reSearch = new RegExp('.*'+search+'.*','i');
    } catch (e) {
      reSearch = new RegExp('.*'+search.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")+'.*','i');
    }

    document.querySelectorAll('.tracker-item').forEach(function(item) {
      let visible = true;
      if ((rarity !== true) && (rarity.indexOf(item.dataset.rarity) === -1)) {
        visible = false;
      } else if ((type !== true) && (type.indexOf(item.dataset.type) === -1)) {
        visible = false;
      } else if ((element !== true) && (element.indexOf(item.dataset.element) === -1)) {
        visible = false;
      } else if ((style !== true) && (style.indexOf(item.dataset.style) === -1)) {
        visible = false;
      } else if ((maxevo !== true) && (maxevo.indexOf(item.dataset.maxevo) === -1)) {
        visible = false;
      } else if ((released !== true) && (released.indexOf(item.dataset.released) === -1)) {
        visible = false;
      } else if ((owned !== true) && (owned.indexOf(item.dataset.owned) === -1)) {
        visible = false;
      }
      if (visible && (gender !== true)) {
        visible = false;
        for (let i=0; i<gender.length; i++) {
          if (item.dataset.gender.indexOf(gender[i]) !== -1)
            visible = true;
        }
      }
      if (visible && (obtain !== true)) {
        let obtain_arr = item.dataset.obtain.split(';');
        if (obtain === 'other') {
          let intersect = knownObtains.filter(value => -1 !== obtain_arr.indexOf(value));
          visible = (intersect.length === 0);
        } else {
          let intersect = obtain.filter(value => -1 !== obtain_arr.indexOf(value));
          visible = (intersect.length > 0);
        }
      }
      if (visible && (weapon !== true)) {
        let weapon_arr = item.dataset.weapon.split(',');
        let intersect = weapon.filter(value => -1 !== weapon_arr.indexOf(value));
        visible = (intersect.length > 0);
      }
      if (visible && (race !== true)) {
        let race_arr = item.dataset.race.split(',');
        let intersect = race.filter(value => -1 !== race_arr.indexOf(value));
        visible = (intersect.length > 0);
      }
      if (visible && (search.length > 0)) {
        visible = reSearch.test(item.dataset.id) || reSearch.test(item.dataset.name);
      }
      toggleVisible(item, visible);
    });
    document.querySelectorAll('.tracker-box').forEach(function(box) {
      let visible = false;
      for (let i = 0; i < box.childNodes.length; i++)
        if (box.childNodes[i].style.display !== 'none') {
          visible = true;
          break;
        }
      toggleVisible(box, visible);
    });

    console.timeEnd('updating');
  };

  // Save valid obtain values for filtering
  document.querySelectorAll('#tracker-filter-obtain .items > label').forEach((label) => {
    if ('value' in label.dataset)
      knownObtains.push(label.dataset.value);
  });

  window.addEventListener('hashchange', function() {
    if (window.location.hash !== lastHash) {
      readHash();
      updateUncap();
      updateItems();
    }
  }, false);


  console.time('moving');
  ['fire','water','earth','wind','light','dark','any'].forEach(function(element) {
    ['ssr','sr','r'].forEach(function(rarity) {
      moveItems('c', element, rarity);
      moveItems('s', element, rarity);
    });
  });
  console.timeEnd('moving');

  // Clicking a tracker item
  addEventForChild(document, 'click', '.tracker-item', function(event, item) {
    if (event.button !== 0) return; // Do nothing unless it's a left-mouse-button click
    event.preventDefault();
    if (event.target.classList.contains('tracker-uncap-star'))
      return;
    console.time('Click Item');
    evolve(item, true);
    makeHash();
    console.timeEnd('Click Item');
  });

  // Clicking the star of a tracker item
  addEventForChild(document, 'click', '.tracker-uncap-star', function(event, star) {
    event.preventDefault();
    let level = [...star.parentElement.children].indexOf(star) + 2;
    let item = star.closest('.tracker-item');
    if (star.classList.contains('selected') && ((star.nextElementSibling == null) || !star.nextElementSibling.classList.contains('selected')))
      level -= 1;
    evolve(item, level);
  });


  addEventForChild(document, 'click', '.mw-ui-button-group > label:not(.mw-ui-disabled)', function(event, label) {
    console.time('Click Label');
    let group = label.parentElement;
    let all = group.querySelector('label[data-value="*"]');

    if (label === all) {
      if (!all.classList.contains('mw-ui-progressive')) {
        group.querySelectorAll('label').forEach(function(sibling) {
          sibling.classList.remove('mw-ui-progressive');
        });
        all.classList.add('mw-ui-progressive');
      }
    } else if (group.childElementCount === 3) {
        group.querySelectorAll('label').forEach(function(sibling) {
          sibling.classList.remove('mw-ui-progressive');
        });
        label.classList.add('mw-ui-progressive');
    } else {
      all.classList.remove('mw-ui-progressive');
      label.classList.toggle('mw-ui-progressive');
      if (group.querySelectorAll('.mw-ui-progressive').length < 1)
        all.classList.add('mw-ui-progressive');
    }
    setTimeout(function() {
      console.time('Click Label Update');
      updateItems();
      makeHash();
      console.timeEnd('Click Label Update');
    }, 0);
    console.timeEnd('Click Label');
  });

  addEventForChild(document, 'click', '#tracker-character-uncap, #tracker-summon-uncap', function() { updateUncap(); });
  addEventForChild(document, 'change', '#tracker-search', function() { updateItems(); });
  addEventForChild(document, 'keyup', '#tracker-search', function() { updateItems(); });

  addEventForChild(document, 'click', '#tracker-local-save', function() {
    makeHash();
    localStorage.collectionTrackerState = window.location.hash;
  });
  addEventForChild(document, 'click', '#tracker-local-load', function() {
    window.location.hash = localStorage.collectionTrackerState;
    setTimeout(function() {
      window.location.hash = localStorage.collectionTrackerState;
    }, 0);
  });

  readHash();
  updateUncap();
  updateItems();
})();
</script>