MediaWiki:Gadget-GECharts-sandbox.js

This is the current revision of this page, as edited by Alex (talk | contribs) at 12:06, 20 October 2024. The present address (URL) is a permanent link to this version.

(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

After saving, you may need to bypass your browser's cache to see the changes. For further information, see Wikipedia:Bypass your cache.

  • In most Windows and Linux browsers: Hold down Ctrl and press F5.
  • In Safari: Hold down ⇧ Shift and click the Reload button.
  • In Chrome and Firefox for Mac: Hold down both ⌘ Cmd+⇧ Shift and press R.
/**
 * Grand Exchange Charts
 * Displays price data of item(s) in a chart
 *
 * Highstock docs <https://api.highcharts.com/highstock/>
 * Highstock change log <https://www.highcharts.com/blog/changelog/#highstock>
 *
 * @author Joeytje50
 * @author Cqm
 * @author JaydenKieran
 * @author Gaz Lloyd
 * 
 * @todo move Highcharts to a core ResourceLoader module
 *
 * @todo use a consistent variable for the chart id
 *       currently it's one of c, i or id
 * @todo remove script URLs (javascript:func) in favour of onclick events
 *       may require attaching the events after the some parts have loaded
 * @todo fix averages
 */

/*global jQuery, mediaWiki, rswiki, Highcharts, wgPageName, wgTitle, wgNamespaceNumber */

'use strict';

/**
 * Cache mw.config variables
 */
function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
var conf = mw.config.get(['wgNamespaceNumber', 'wgPageName', 'wgTitle', 'wgSiteName']),
  // Are we on OSRS? Impacts selectors and volume labels / multipliers
  isOSRS = conf.wgSiteName == "RuneRealm Wiki",
  // Volume label depends on which wiki we're on
  volumeLabel = "Daily volume",
  gameVersion = isOSRS ? 'osrs' : 'rs',
  /**
   * <doc>
   *
   * @todo replace `_GEC` wih this
   */
  gec = {},
  // @todo document each of these
  _GEC = {
    AIQueue: [],
    AILoaded: [],
    AIData: [],
    addedData: [],
    average: parseInt((location.hash.match(/#a=([^#]*)/) || [])[1], 10) || '',
    urlCache: {},
    annotationsVisible: true
  },
  /**
   * Startup methods
   */
  self = {
    /**
     * Loads and implements any required dependencies
     */
    deps: function deps() {
      if (!mw.loader.getState('rs.highcharts')) {
        mw.loader.implement('rs.highcharts', ['https://code.highcharts.com/stock/highstock.js'], {}, {});
      }
      if (!mw.loader.getState('rs.highcharts-annotations')) {
        mw.loader.implement('rs.highcharts-annotations', ['https://code.highcharts.com/modules/annotations.js'], {}, {});
      }
      mw.loader.using(['mediawiki.util', 'mediawiki.api', 'rs.highcharts', 'rs.highcharts-annotations', 'oojs-ui-core', 'oojs-ui.styles.icons-media'], self.init);
    },
    /**
     * Initial loading function
     */
    init: function init(req) {
      window.Highcharts = req('rs.highcharts');
      req('rs.highcharts-annotations')(window.Highcharts); //load the annotations module, shouldn't need to store the return value anywhere
      (function () {
        var newhash = location.hash.replace(/\.([0-9a-f]{2})/gi, function (_, first) {
          return String.fromCharCode(parseInt(first, 16));
        }).replace(/ /g, '_');
        if (newhash && newhash.match(/#[aiz]=/)) {
          location.hash = newhash;
        }
      })();
      $('.GEdatachart').attr('id', function (c) {
        return 'GEdatachart' + c;
      });
      $('.GEdataprices').attr('id', function (c) {
        return 'GEdataprices' + c;
      });
      $('.GEChartBox').each(function (c) {
        $(this).find('.GEChartItems').attr('id', 'GEChartItems' + c);
      });
      Highcharts.setOptions({
        lang: {
          // @todo can this be done with CSS?
          resetZoom: null,
          numericSymbols: ['K', 'M', 'B', 'T', 'Qd', 'Qt']
        }
      });

      // globals to maintain javascript hrefs
      window._GEC = _GEC;
      window.popupChart = popupChart;
      window.addItem = chart.addItem;
      window.removeGraphItem = chart.removeItem;
      self.buildPopup();
      self.setupCharts();
    },
    /**
     * <doc>
     */
    makeOOUI: function makeOOUI(c) {
      var averageRangeInput, addItemInput, submitButton, resetButton, annotationsToggle, fieldset, permalink;
      averageRangeInput = new OO.ui.NumberInputWidget({
        min: 1,
        value: 30,
        id: 'average' + c
      });
      averageRangeInput.$element.data('ooui-elem', averageRangeInput);
      addItemInput = new OO.ui.TextInputWidget({
        id: 'extraItem' + c
      });
      addItemInput.$element.data('ooui-elem', addItemInput);
      submitButton = new OO.ui.ButtonInputWidget({
        label: 'Submit',
        flags: ['primary', 'progressive']
      });
      resetButton = new OO.ui.ButtonInputWidget({
        label: 'Reset'
      });
      permalink = new OO.ui.ButtonInputWidget({
        label: 'Permanent link',
        title: 'Permanent link to the current chart settings and items. Right click to copy the url.',
        id: 'GEPermLink' + c
      });
      permalink.$element.data('ooui-elem', permalink);
      permalink.setData('/w/RuneScape:Grand_Exchange_Market_Watch/Chart');
      permalink.on('click', function () {
        window.open(permalink.getData(), '_blank');
      });
      averageRangeInput.on('enter', function () {
        addItem(c);
      });
      addItemInput.on('enter', function () {
        addItem(c);
      });
      submitButton.on('click', function () {
        addItem(c);
      });
      resetButton.on('click', function () {
        addItemInput.setValue('');
        averageRangeInput.setValue(30);
      });
      annotationsToggle = new OO.ui.ToggleSwitchWidget({
        value: true
      });
      annotationsToggle.on('change', function () {
        window._GEC.annotationsVisible = annotationsToggle.getValue();
        for (var i = 0; i < window._GEC['chart' + c].annotations.length; i++) {
          window._GEC['chart' + c].annotations[i].setVisibility(window._GEC.annotationsVisible);
        }
      });
      fieldset = new OO.ui.FieldsetLayout();
      fieldset.addItems([new OO.ui.FieldLayout(averageRangeInput, {
        label: 'Average (days)'
      }), new OO.ui.FieldLayout(addItemInput, {
        label: 'Add new item'
      }), new OO.ui.HorizontalLayout({
        items: [submitButton, resetButton, permalink]
      }), new OO.ui.FieldLayout(annotationsToggle, {
        label: 'Show annotations?'
      })]);
      fieldset.$element.css('width', '50%');
      return fieldset.$element;
    },
    buildPopup: function buildPopup() {
      var close;
      close = new OO.ui.ButtonWidget({
        icon: 'close'
      });
      close.on('click', function () {
        popupChart(false);
      });
      $('body').append($('<div>').attr('id', 'GEchartpopup').css('display', 'none').append($('<div>').attr('id', 'closepopup').append(close.$element), self.makeOOUI('popup'), $('<div>').attr('id', 'addedItemspopup'), $('<div>').attr('id', 'GEpopupchart')));
    },
    /**
     * <doc>
     */
    setupCharts: function setupCharts() {
      $('div.GEdatachart').each(function (c) {
        var $dataPrices = $('#GEdataprices' + c),
          $dataChart = $('#GEdatachart' + c),
          dataItem = $dataPrices.attr('data-item'),
          isSmall = $dataChart.hasClass('smallChart'),
          isMedium = $dataChart.hasClass('mediumChart'),
          isIndexChart = /index/i.test(dataItem),
          selector = isOSRS ? '.infobox *, .infobar *, .infobox-switch-resources.infobox-resources-Infobox_Item *' : '.infobox *, .infobar *, .rsw-infobox *, .infobox-switch-resources.infobox-resources-Infobox_Item *',
          isInfobox = $dataPrices.is(selector),
          itemName = dataItem || conf.wgTitle.split('/')[0],
          dataList,
          yAxis,
          zoom;
        if (!$dataPrices.length) {
          return;
        }

        // setting up the form and chart elements
        if (!isSmall && !isMedium) {
          $dataChart.before(self.makeOOUI(c), $('<div>').attr('id', 'addedItems' + c));
        }
        getData(c, isSmall, isMedium, undefined, function (data) {
          var dataList = data[0];
          var yAxis = data[1];
          if (itemName.toLowerCase() !== 'blank') {
            zoom = parseInt((location.hash.match(/#z=([^#]*)/) || [])[1]);
            zoom = zoom && zoom <= 6 && zoom >= 0 ? zoom - 1 : zoom === 0 ? 0 : 2;
          }
          var enlarge = new OO.ui.ButtonWidget({
            label: 'Enlarge chart',
            icon: 'fullScreen',
            id: 'gec-enlarge-' + c
          });
          enlarge.$element.css("font-size", "13px");

          // @todo this doesn't do anything on small charts
          //       is it supposed to?
          //var zoomOut = '<a href="javascript:_GEC.chart' + c + '.zoomOut();" style="text-decoration:underline;color:inherit;font-size:inherit;">Zoom out</a>';

          //generating the chart
          _GEC['chart' + c] = new Highcharts.StockChart({
            chart: {
              renderTo: 'GEdatachart' + c,
              backgroundColor: 'white',
              plotBackgroundColor: 'white',
              zoomType: '',
              //height: isSmall?210:null,
              events: {
                redraw: function redraw() {
                  _GEC.thisid = this.renderTo.id.replace('GEdatachart', '').replace('GEpopupchart', 'popup');
                  setTimeout(function () {
                    setChartExtremes(_GEC.thisid);
                  }, 0);
                }
              }
            },
            legend: {
              enabled: !isSmall && !isMedium,
              backgroundColor: 'white',
              align: 'right',
              layout: 'vertical',
              verticalAlign: 'top',
              y: 85
            },
            responsive: {
              rules: [{
                condition: {
                  //maxWidth: 500
                },
                chartOptions: {
                  legend: {
                    align: 'center',
                    verticalAlign: 'bottom',
                    layout: 'horizontal'
                  }
                }
              }]
            },
            title: {
              text: isSmall || isMedium ? isInfobox || isMedium ? enlarge.$element[0].outerHTML : itemName : 'Grand Exchange Market Watch',
              useHTML: true,
              style: {
                color: 'black',
                fontSize: isSmall ? enlarge ? '13px' : '15px' : '18px'
              }
            },
            subtitle: {
              text: isSmall ? isInfobox ? '' : enlarge.$element[0].outerHTML : itemName.toLowerCase() == 'blank' ? 'Historical chart' : itemName,
              useHTML: true,
              y: 50,
              style: {
                color: '#666',
                fontSize: isSmall ? '13px' : '15px'
              }
            },
            rangeSelector: {
              enabled: !isSmall && !isMedium,
              selected: zoom,
              inputBoxStyle: {
                right: '15px',
                display: isSmall || isMedium ? 'none' : 'block'
              },
              inputStyle: {
                width: '100px'
              },
              inputDateFormat: "%e-%b-%Y",
              buttonTheme: {
                "class": 'zoomButton'
              },
              buttons: [{
                type: 'month',
                count: 1,
                text: '1m'
              }, {
                type: 'month',
                count: 2,
                text: '2m'
              }, {
                type: 'month',
                count: 3,
                text: '3m'
              }, {
                type: 'month',
                count: 6,
                text: '6m'
              }, {
                type: 'year',
                count: 1,
                text: '1y'
              }, {
                type: 'all',
                text: 'All'
              }]
            },
            plotOptions: {
              series: {
                enableMouseTracking: !isSmall,
                dataGrouping: {
                  dateTimeLabelFormats: {
                    day: ['%A, %e %B %Y', '%A, %e %B', '-%A, %e %B %Y'],
                    week: ['Week from %A, %e %B %Y', '%A, %e %B', '-%A, %e %B %Y'],
                    month: ['%B %Y', '%B', '-%B %Y'],
                    year: ['%Y', '%Y', '-%Y']
                  }
                }
              }
            },
            tooltip: {
              enabled: !isSmall,
              valueDecimals: isIndexChart ? 2 : 0,
              headerFormat: '<span style="font-size: 12px">{point.key}</span><br/>',
              xDateFormat: "%A, %e %B %Y"
            },
            navigator: {
              xAxis: {
                dateTimeLabelFormats: {
                  day: "%e-%b",
                  week: "%e-%b",
                  month: "%b-%Y",
                  year: "%Y"
                },
                minTickInterval: 24 * 3600 * 1000 //1 day
              },
              maskFill: 'none',
              enabled: !(isSmall || isMedium)
            },
            credits: {
              enabled: false
            },
            xAxis: [{
              lineColor: '#666',
              tickColor: '#666',
              dateTimeLabelFormats: {
                day: "%e-%b",
                week: "%e-%b",
                month: "%b-%Y",
                year: "%Y"
              },
              minTickInterval: 24 * 3600 * 1000,
              //1 day
              scrollbar: {
                enabled: false,
                showFull: false
              }
            }],
            yAxis: yAxis,
            series: dataList,
            colors: window.GEMWChartColors || ['#4572A7', '#AA4643', '#89A54E', '#80699B', '#3D96AE', '#DB843D', '#92A8CD', '#A47D7C', '#B5CA92'],
            annotations: [data[2]]
          });
          var items = ($('#GEChartItems' + c).html() || '').split(',');
          var noAdd = [];
          var i;
          for (i = 0; i < items.length; i++) {
            items[i] = items[i].trim();
            if (items[i]) {
              addItem(c, items[i]);
            } else {
              noAdd.push(1);
            }
          }
          if (items.length == noAdd.length && _GEC['chart' + c].series[0].name.toLowerCase() != 'blank') setChartRange(c);

          //adjusting the axes extremes (initial load)
          setChartExtremes(c);

          //loading the chart and additional price info when the page is ready
          if ((conf.wgNamespaceNumber == 112 && conf.wgTitle.split('/')[1] == 'Data' || conf.wgPageName == 'RuneScape:Grand_Exchange_Market_Watch/Chart') && location.hash.match('#i=') !== null) {
            var hash = location.hash;
            items = decodeURIComponent((hash.match(/#i=([^#]*)/) || [])[1] || '').replace(/_/g, ' ').split(',');
            for (i = 0; i < items.length; i++) {
              if (items[i].match(/^\s*$/) === null) addItem(0, items[i]);
            }
          }
          var $enlargeEle = $("#gec-enlarge-" + c);
          if ($enlargeEle.length) {
            $enlargeEle.on("click", function () {
              popupChart(c);
            });
          }
          ;
        });
      });
    }
  },
  /**
   * General helper methods
   */
  util = {
    /**
     * <doc>
     *
     * @todo replace with $.extend
     *
     * @param a {object}
     * @param b {object} (optional)
     *
     * @return {object}
     */
    cloneObj: function cloneObj(a, b) {
      if (_typeof(a) !== 'object') {
        return '';
      }
      if (_typeof(b) !== 'object') {
        b = {};
      }
      for (var key in a) {
        if (a.hasOwnProperty(key)) {
          b[key] = a[key];
        }
      }
      return b;
    },
    /**
     * Averages prices across a specified time interval
     *
     * @param arr {array} Array of arrays, where each member of `arr`
     *                    is in the format [time, price]
     *                    Which is how we store the price data
     *                    @example [x-coord, y-coord]
     * @param amt {number} Interval to average across in days
     * @param round {number} (optional) Number of decimal places to round to
     *                       Defaults to 0
     *
     * @return {array} Array of arrays, where each member of the return array
     *                 is in the format [time, price] (as above)
     *                 and
     */
    avg: function avg(arr, amt, round) {
      amt = amt || arr.length;
      // convert `round` into a number we can use for rounding
      round = Math.pow(10, round || 0);
      var avgs = [],
        list = [],
        i;

      // adds each price to `list`
      // when `amt` is reached, average the contents of `list`
      //
      // each iteration after `amt` is reached averages the contents of `list`
      // which is continuously being updated as each iteration
      // after `amt` is reached replaces a member of `list`
      // @example when `i` is 31 the current price replaces `list[1]`
      //          when `i` is 35 the current price replaces `list[5]`
      for (i = 0; i < arr.length; i++) {
        list[i % amt] = arr[i][1];
        if (i >= amt) {
          avgs.push([
          // don't modify the time (y-coord)
          arr[i][0], Math.round(util.sum(list) / list.length * round) / round]);
        }
      }
      return avgs;
    },
    /**
     * Finds the sum of numbers in an array
     * Only called by `util.avg`
     *
     * @param arr {array} Array of number to find the sum of
     *
     * @return {number} Sum of the numbers in `arr`
     */
    sum: function sum(arr) {
      var total = 0,
        i;
      for (i = 0; i < arr.length; i++) {
        total += parseFloat(arr[i], 10);
      }
      return total;
    },
    /**
     * Rounds and formats numbers
     *
     * @example 12345        -> 12.3K
     * @example 1234567      -> 1.2M
     * @example 123456789012 -> 123.4M
     *
     * @param num {number|string} Number to format
     *
     * @return {string} Formatted number
     */
    toKMB: function toKMB(num) {
      // strip commas from number string
      // as `parseInt` will interpret them as a decimal separator
      // pass numbers and string to `parseInt` to convert floats too
      num = parseInt(typeof num === 'string' ? num.replace(/,/g, '') : num, 10);
      var neg = num < 0 ? '-' : '';
      num = Math.abs(num);

      // `1eX` is shorthand for `Math.pow( 10, X )`
      if (num >= 1e10) {
        num = Math.round(num / 1e8) / 10;
        num += 'B';
      } else if (num >= 1e7) {
        num = Math.round(num / 1e5) / 10;
        num += 'M';
      } else if (num >= 1e4) {
        num = Math.round(num / 100) / 10;
        num += 'K';
      }
      return rs.addCommas(neg + num);
    },
    /**
     * Capitalises first character of a string
     *
     * @source <https://stackoverflow.com/a/1026087>
     *
     * @param str {string}
     *
     * @return {string}
     */
    ucFirst: function ucFirst(str) {
      return str.charAt(0).toUpperCase() + str.slice(1);
    },
    /**
     * Sort data points in the graph data before passing it to the charts api
     */
    sortPoints: function sortPoints(a, b) {
      a = a.replace(/'/g, '').split(':')[0];
      b = b.replace(/'/g, '').split(':')[0];
      return a - b;
    }
  },
  /**
   * Chart methods
   */
  chart = {
    /**
     * <doc>
     *
     * @param id {string|number}
     * @param match {string} is normally the 'line' that isn't an item's price data
     *                       such as average or volume
     *
     * @return {number}
     */
    getSeriesIndex: function getSeriesIndex(id, match) {
      var chart = _GEC['chart' + id],
        series = chart.series,
        i;
      if (chart) {
        for (i = 0; i < series.length; i++) {
          if (series[i].name.match(match)) {
            return i;
          }
        }
        return -1;
      }

      // @todo what happens if !chart
    },
    /**
     * Creates a URL with preset options
     *
     * @todo change to url params
     * @todo document the individual params/options
     *
     * @param id {number|string}
     *
     * @return {string}
     */
    permLinkUrl: function permLinkUrl(id) {
      var chart = _GEC['chart' + id],
        xt = chart.xAxis[0].getExtremes(),
        series = chart.series,
        minDate = new Date(xt.min).toDateString().split(' ').slice(1).join('_'),
        maxDate = new Date(xt.max).toDateString().split(' ').slice(1).join('_'),
        inputAvg = $('#average' + id).data('ooui-elem').getNumericValue(),
        urlHash = '#t=' + minDate + ',' + maxDate,
        items = '',
        i;
      if (!isNaN(inputAvg)) {
        urlHash += '#a=' + inputAvg;
      }
      for (i = 0; i < series.length; i++) {
        if (series[i].name == 'Navigator' || series[i].name.match('average')) {
          continue;
        }

        // separate items with commas
        if (items) {
          items += ',';
        }

        // @todo url encode this?
        items += series[i].name.replace(/ /g, '_');
      }
      urlHash += '#i=' + items;

      // @todo hide the redirect h2
      return '/w/RuneScape:Grand_Exchange_Market_Watch/Chart' + urlHash;
    },
    /**
     * Add a new item to the chart
     *
     * @param i
     * @param it {string} (optional)
     */
    addItem: function addItem(i, it) {
      _GEC.chartid = i;
      var OOUIextraItemPresent = $('#extraItem' + i).length > 0,
        OOUIextraItem = $('#extraItem' + i).data('ooui-elem'),
        item = (it || '').trim() || OOUIextraItem.getValue(),
        dataItems = ['#addedItems' + i + ' [data-item]', '#GEdataprices' + i + '[data-item]'],
        $dataItems = $(dataItems.join(',')).map(function () {
          return $(this).attr('data-item').toLowerCase();
        }),
        $addedItems = $('#addedItems' + i),
        id,
        data,
        series,
        seriesIndex,
        gecchartid = i,
        index;
      if (item && item.length) {
        index = -1;
        for (var i2 = 0; i2 < _GEC.AIQueue.length; i2++) {
          if (_GEC.AIQueue[i2] == item.toLowerCase()) {
            index = i2;
            break;
          }
        }
        if (
        // @todo should a number passed to .get()
        $dataItems.get().indexOf(item.toLowerCase()) !== -1 || index !== -1) {
          if (!it) {
            alert(item + ' is already in the graph.');
          }
          if (OOUIextraItemPresent) {
            OOUIextraItem.setValue('');
          }
          return false;
        }
        if (OOUIextraItemPresent) {
          OOUIextraItem.setDisabled(true);
        }
        $.get('/api.php', {
          action: 'query',
          prop: 'revisions',
          rvprop: 'content',
          format: 'json',
          titles: 'Module:Exchange/' + util.ucFirst(item)
        }).then(function (data, textStatus) {
          var OOUIextraItem = $('#extraItem' + gecchartid).data('ooui-elem'),
            pages = data.query.pages;
          if (textStatus !== 'success') {
            alert('An error occured while loading ' + item);
            mw.log(data);
          }
          var matches = [];
          var pageMissing = false;
          if (pages[-1]) {
            pageMissing = true;
          } else {
            var exchangeData = pages[Object.keys(pages)[0]].revisions[0]['*'];
            matches = exchangeData.match(/itemId\D*(\d*)/);
            if (matches.length !== 2) {
              pageMissing = true;
            }
          }
          // page not found
          if (pageMissing) {
            if (OOUIextraItem.getValue().length) {
              alert('The item ' + item + ' doesn\'t exist on our Grand Exchange database.');
              OOUIextraItem.setDisabled(false).setValue('');
              return false;
            }
            _GEC.AILoaded.push(false);
            if (_GEC.AIData.length && _GEC.AIQueue.length == _GEC.AILoaded.length) {
              loadChartsQueueComplete(gecchartid);
            } else if (!_GEC.AIData.length) {
              setChartRange(gecchartid);
            }
            OOUIextraItem.setDisabled(false).setValue('');
            return false;
          }
          var itemId = matches[1];
          return $.getJSON("https://api.weirdgloop.org/exchange/history/" + gameVersion + "/all?compress=true&id=" + itemId);
        }).then(function (data, textStatus) {
          if (data === false) return;
          $.getJSON(mw.util.getUrl('Module:Exchange/' + util.ucFirst(item) + '/annotations.json', {
            action: 'raw',
            'ctype': 'application/json'
          })).then(function (annodata) {
            return annodata;
          }, function () {
            return [];
          } //handles the page not existing
          ).then(function (annodata) {
            _GEC.AIData.push({
              name: item,
              data: Object.values(data)[0],
              id: item,
              gecchartid: gecchartid,
              lineWidth: 2,
              annotations: formatAnnoList(annodata, Object.values(data)[0])
            });
            _GEC.AILoaded.push(item);
            if (getSeriesIndex(gecchartid, 'average') !== -1) {
              _GEC['chart' + gecchartid].series[getSeriesIndex(gecchartid, 'average')].remove();
            }
            if (_GEC.AIQueue.length === _GEC.AILoaded.length) {
              // This is always true when only 1 item is being loaded.
              loadChartsQueueComplete(gecchartid);
            }
          });
        });
        _GEC.AIQueue.push({
          item: item.toLowerCase(),
          chart: gecchartid
        });

        // @todo when does this happen
        /* This happens when there are no further items added to the charts, i.e. when the original item is the only one.
           This is indeed a flawed test, since it won't work on GEMW/C, where there is no original item in the chart.
           This should be replaced with another test that also works on GEMW/C.
         */
      } else if ($addedItems.html().match(/^\s*$/) || conf.wgPageName == 'RuneScape:Grand_Exchange_Market_Watch/Chart' && $addedItems.find('a').length === 1) {
        id = i === 'popup' ? $('#GEchartpopup').attr('data-chartid') : i;
        getData(id, false, false, i, function (data) {
          series = _GEC['chart' + i].series;
          seriesIndex = getSeriesIndex(i, 'average');

          //remove an average line if it already exists
          if (seriesIndex !== -1) {
            series[seriesIndex].remove();
          }

          //add average line when there is only 1 item in the chart
          _GEC['chart' + i].addSeries(data[0][1]);
        });
      }
    },
    /**
     * <doc>
     *
     * @param c {number|string}
     */
    loadQueueComplete: function loadQueueComplete(cin, addeditembyscript) {
      var cnum = cin !== 'popup',
        //if cin is a number, we're probably at initial load of one/many charts on a page, so we need to iterate over the entire queue
        c = cnum ? _GEC.AIQueue.length : cin,
        //if not a number, its almost certainly 'popup', for which we only need to reload the popup
        id,
        chartdata,
        isSmall = [],
        isMedium = [],
        data = [],
        i,
        index,
        itemhash,
        $addedItems,
        iname,
        hadBlank;
      if (cnum) {
        //this structure repeats throughout the method: if cnum then loop else do once. probably a better way to do this
        for (i = 0; i < c; i++) {
          isSmall[i] = $('#GEdatachart' + i).hasClass('smallChart');
          isMedium[i] = $('#GEdatachart' + i).hasClass('mediumChart');
        }
      } else {
        isSmall = $('#GEdatachart' + c).hasClass('smallChart');
        isMedium = $('#GEdatachart' + c).hasClass('mediumChart');
      }
      if (cnum) {
        for (i = 0; i < c; i++) {
          if (getSeriesIndex(_GEC.AIQueue[i].chart, volumeLabel) !== -1) {
            id = i === 'popup' ? $('#GEchartpopup').attr('data-chartid') : i;
            getData(id, true, undefined, undefined, function (data) {
              data[1].title.text = 'Price history';
              reloadChart(i, {
                series: data[0],
                yAxis: data[1]
              });
            });
          }
        }
      } else {
        if (getSeriesIndex(c, volumeLabel) !== -1) {
          id = c === 'popup' ? $('#GEchartpopup').attr('data-chartid') : c;
          getData(id, true, undefined, undefined, function (data) {
            data[1].title.text = 'Price history';
            reloadChart(c, {
              series: data[0],
              yAxis: data[1]
            });
          });
        }
      }
      for (i = 0; i < _GEC.AIData.length; i++) {
        index = -1;
        for (var i2 = 0; i2 < _GEC.AIQueue.length; i2++) {
          if (_GEC.AIQueue[i2].item === (_GEC.AIData[i] || {
            name: ''
          }).name.toLowerCase()) {
            index = i2;
            break;
          }
        }
        data[index !== -1 ? index : data.length] = _GEC.AIData[i];
      }
      // @todo should this be `Array.isArray`
      //       or should it default to `{}`
      // @todo test if isSmall is needed in the conditional
      if (cnum) {
        for (i = 0; i < c; i++) {
          if (data[i] === undefined) continue;
          if (isSmall[data[i].gecchartid] && isMedium[data[i].gecchartid] && _typeof(Array.isArray(_GEC.addedData[data[i].gecchartid]))) {
            _GEC.addedData[data[i].gecchartid] = [];
          }
        }
      } else {
        if ((isSmall || isMedium) && _typeof(Array.isArray(_GEC.addedData[data[c].gecchartid]))) {
          _GEC.addedData[data[c].gecchartid] = [];
        }
      }
      for (i = 0; i < data.length; i++) {
        if (data[i]) {
          _GEC['chart' + data[i].gecchartid].addSeries(data[i]);
          if (data[i].annotations && data[i].annotations.labels && data[i].annotations.labels.length) {
            _GEC['chart' + data[i].gecchartid].addAnnotation(data[i].annotations);
          }
        }
        if (cnum && isSmall[data[i].gecchartid]) {
          _GEC.addedData[data[i].gecchartid][i] = data[i];
        }
      }
      if (cnum) {
        for (i = 0; i < c; i++) {
          setChartExtremes(data[i].gecchartid);
          $('#extraItem' + data[i].gecchartid).data('ooui-elem').setDisabled(false).setValue('');
        }
      } else {
        setChartExtremes(c);
        $('#extraItem' + c).data('ooui-elem').setDisabled(false).setValue('');
      }
      itemhash = (location.hash.match(/#i=[^#]*/) || [])[0] || location.hash + '#i=';
      $addedItems = $('#addedItems' + c);
      for (i = 0; i < data.length; i++) {
        if (!data[i]) {
          continue;
        }
        iname = data[i].name;
        if (!$addedItems.text().trim()) {
          $addedItems.append('Remove items from graph: ', $('<a>').attr({
            href: 'javascript:removeGraphItem("' + iname + '","' + c + '")',
            'data-item': iname
          }).text(iname));
          itemhash = '#i=' + iname;
        } else {
          $addedItems.append(', ', $('<a>').attr({
            href: 'javascript:removeGraphItem("' + iname + '","' + c + '")',
            'data-item': iname
          }).text(iname));
          itemhash += ',' + iname;
        }
      }
      if (location.hash.match(/#i=/)) {
        itemhash = location.hash.replace(/#i=[^#]*/, itemhash).replace(/ /g, '_');
      } else {
        itemhash = location.hash + itemhash;
      }
      if ((conf.wgNamespaceNumber == 112 && conf.wgTitle.split('/')[1] == 'Data' || conf.wgPageName == 'RuneScape:Grand_Exchange_Market_Watch/Chart') && itemhash.replace('#i=', '').length) {
        location.hash = itemhash;
      }
      _GEC.AIQueue = [];
      _GEC.AILoaded = [];
      _GEC.AIData = [];
      if (cnum) {
        for (i = 0; i < c; i++) {
          hadBlank = removeGraphItem('Blank', data[i].gecchartid);
          if (hadBlank) {
            setChartRange(data[i].gecchartid);
          }
        }
      } else {
        hadBlank = removeGraphItem('Blank', c);
        if (hadBlank) {
          setChartRange(c);
        }
      }
    },
    /**
     * <doc>
     *
     * @param c {number|string}
     *
     * @return {boolean}
     */
    setRange: function setRange(c) {
      var zoom = parseInt((location.hash.match(/#z=([^#]*)/) || [])[1], 10);
      zoom = zoom && zoom <= 6 && zoom >= 0 ? zoom - 1 : zoom === 0 ? 0 : 2;
      var hash = location.hash;
      var hasT = conf.wgNamespaceNumber === 112 && conf.wgTitle.split('/')[1] === 'Data' || conf.wgPageName === 'RuneScape:Grand_Exchange_Market_Watch/Chart';
      if (typeof c === 'number' && (hasT && !hash.match('#t=') || !hasT)) {
        $('#GEdatachart' + c + ' .zoomButton').eq(zoom).click();
        return true;
      }
      var timespan = decodeURIComponent((hash.match(/#t=([^#]*)/) || [])[1] || '').replace(/_/g, ' ').split(',');
      var dates = [new Date(timespan[0]), new Date(timespan[1])];
      var d = new Date(timespan[0]);
      var extremes = _GEC['chart' + c].xAxis[0].getExtremes();
      if (dates[0] !== 'Invalid Date' && dates[1] === 'Invalid Date' && typeof zoom === 'number') {
        var button = _GEC['chart' + c].rangeSelector.buttonOptions[zoom];
        if (button.type === 'month') {
          d.setMonth(d.getMonth() + button.count);
        } else if (button.type === 'year') {
          d.setYear(d.getFullYear() + button.count);
        } else if (button.type === 'all') {
          d = new Date(extremes.dataMax);
        }
        dates[1] = d;
      }
      if (dates[0] !== 'Invalid Date' && dates[1] !== 'Invalid Date') {
        _GEC['chart' + c].xAxis[0].setExtremes(dates[0].getTime(), dates[1].getTime());
        return true;
      }
      return false;
    },
    /**
     * <doc>
     *
     * @param c {number|string}
     * @param change {object}
     */
    reload: function reload(c, change) {
      var options = _GEC['chart' + c].options;
      if (!options) {
        // @todo do we need to return `false` here
        // @todo when does this happen
        return false;
      }
      $.extend(options, change);
      _GEC['chart' + c] = new Highcharts.StockChart(options);
    },
    /**
     * <doc>
     *
     * @param item {string}
     * @param c {number|string}
     *
     * @return {boolean}
     */
    removeItem: function removeItem(item, c) {
      var series = _GEC['chart' + c].series,
        id,
        i,
        newhash,
        data;

      // find the item we want to remove
      for (i = 0; i < series.length; i++) {
        if (series[i].name.match(item)) {
          id = i;
        }
      }

      // @todo when does this happen
      //       when we can't find the item?
      if (typeof id !== 'number') {
        return false;
      }

      // remove item from url hash
      newhash = location.hash.replace(/_/g, ' ').replace(new RegExp('(#i=[^#]*),?' + item, 'i'), '$1').replace(/,,/g, ',').replace(/,#/g, '#').replace(/#i=,/g, '#i=').replace(/#i=($|#)/, '$1').replace(/ /g, '_');
      if (newhash.replace('#i=', '').length) {
        location.hash = newhash;
      } else if (location.hash.length) {
        location.hash = '';
      }

      // remove the item from the chart
      series[id].remove();
      // reset extremes?
      setChartExtremes(c);

      // @todo can we cache #addedItems somehow
      // remove item from list at top of graph
      $('#addedItems' + c + ' [data-item="' + item + '"]').remove();
      // cleanup list
      $('#addedItems' + c).html($('#addedItems' + c).html().replace(/, , /g, ', ').replace(/, $/, '').replace(': , ', ': '));

      // if the list is empty show average, volume and item stats again
      if (!$('#addedItems' + c + ' [data-item]').length) {
        $('#addedItems' + c).empty();
        id = c == 'popup' ? $('#GEchartpopup').attr('data-chartid') : c;
        data = getData(id, false, false, 'popup', function (data) {
          reloadChart(c, {
            series: data[0],
            yAxis: data[1]
          });
        });
      }
      return true;
    },
    /**
     * <doc>
     *
     * @param i {number|string}
     */
    popup: function popup() {},
    /**
     * <doc>
     *
     * @param i
     */
    setExtremes: function setExtremes(i) {
      var ch = _GEC['chart' + i],
        exts = _GEC['chart' + i].yAxis[0].getExtremes();
      if (exts.dataMin * 0.95 !== exts.userMin || exts.dataMax * 1.05 !== exts.userMax) {
        ch.yAxis[0].setExtremes(exts.dataMin * 0.95, exts.dataMax * 1.05);
        if (ch.yAxis[2]) {
          exts = ch.yAxis[1].getExtremes();
          ch.yAxis[1].setExtremes(0, exts.dataMax * 1.05);
        }
      }
      if (i === 'popup') {
        // @todo use onclick event
        $('#GEPermLink' + i).data('ooui-elem').setData(chartPermLinkUrl(i));
      }
    },
    /**
     * <doc>
     *
     * @param c {number|string}
     * @param isSmall {boolean}
     * @param avginput {number|string} (optional)
     *        number component of input element used for altering the average interval
     *        when the interval is in days
     *        when is this different to `c`?
     *
     * @return {array} 2 item array containing X and Y respectively
     *                 @todo expand on what X and Y are
     */
    getData: function getData() {}
  },
  // map old functions to new locations until uses are fixed
  getSeriesIndex = chart.getSeriesIndex,
  chartPermLinkUrl = chart.permLinkUrl,
  addItem = chart.addItem,
  removeGraphItem = chart.removeItem,
  reloadChart = chart.reload,
  setChartRange = chart.setRange,
  setChartExtremes = chart.setExtremes,
  loadChartsQueueComplete = chart.loadQueueComplete;
// popupChart = chart.popup;
// getData = chart.getData;

// chart-related general functions

function popupChart(i) {
  var $popup = $('#GEchartpopup'),
    $overlay = $('#overlay'),
    options,
    data,
    n;
  if (!$popup.length) {
    return false;
  }
  if ($overlay.length) {
    $overlay.toggle();
  } else {
    $popup.before($('<div>').attr('id', 'overlay').css('display', 'block'));
    $overlay = $('#overlay');
  }
  $overlay.on('click', function () {
    popupChart(false);
  });
  if (typeof i === 'number') {
    $(document).keydown(function (e) {
      // Esc
      if (e.which === 27) {
        popupChart(false);
      }
    });
  } else {
    // @todo only remove our event
    $(document).off('keydown');
  }
  if (typeof i === 'boolean' && !i) {
    $popup.hide();
    $('#addedItemspopup').html('');
  } else {
    $popup.toggle();
  }
  if (typeof i === 'number' && $popup.attr('data-chartid') !== i) {
    $('#averagepopup').data('ooui-elem').setValue(_GEC.average);
    $popup.attr('data-chartid', i);
    options = {};
    getData(i, false, false, 'popup', function (data) {
      var dataList = data[0];
      var yAxis = data[1];
      // @todo can this be replaced with $.extend?
      // @todo what is this supposed to do?
      util.cloneObj(_GEC['chart' + i].options, options);
      options.chart.renderTo = 'GEpopupchart';
      options.legend.enabled = true;
      options.title.text = 'Grand Exchange Market Watch';
      options.title.style.fontSize = '18px';
      options.subtitle.text = options.series[0].name;
      options.subtitle.y = 35;
      options.subtitle.style.fontSize = '15px;';
      options.chart.zoomType = '';
      options.rangeSelector.enabled = true;
      options.rangeSelector.inputBoxStyle.display = 'block';
      options.plotOptions.series.enableMouseTracking = true;
      options.tooltip.enabled = true;
      options.navigator.enabled = true;
      options.credits.enabled = false;
      options.series = [{}];
      options.series = _GEC.addedData[i] ? [dataList[0]] : dataList;
      options.yAxis = yAxis;
      options.annotations = [data[2]];
      _GEC.chartpopup = new Highcharts.StockChart(options);
      if (_GEC.addedData[i]) {
        for (n = 0; n < _GEC.addedData[i].length; n++) {
          _GEC.chartpopup.addSeries(_GEC.addedData[i][n]);
        }
      }
      setChartExtremes('popup');
      _GEC.chartpopup.redraw();
      $('#GEPermLinkpopup').data('ooui-elem').setData(chartPermLinkUrl('popup'));
    });
  }
}
function rg(num) {
  var colour = 'red';
  if (num > 0) {
    colour = 'green';
  } else if (num === 0) {
    colour = 'blue';
  }
  return colour;
}
function formatAnnoList(annoData, prices) {
  var annoList = {
    labelOptions: {
      verticalAlign: 'bottom',
      overflow: 'justify',
      y: -5
    },
    draggable: '',
    visible: window._GEC.annotationsVisible,
    labels: []
  };
  for (var i = 0; i < annoData.length; i++) {
    var ad = annoData[i];
    var label = ad.title;
    if (!(ad.link === undefined || ad.link === null || ad.link === '')) {
      var labelUrl;
      if (ad.link.startsWith('https://') || ad.link.startsWith('http://')) {
        labelUrl = ad.link;
      } else {
        labelUrl = mw.util.getUrl(ad.link);
      }
      label = '<a href="' + labelUrl + '" target="_blank">' + ad.title + '</a>';
    }
    var ts = Date.parse(ad.timestamp);
    if (!isNaN(ts)) {
      var priceAtTs = 0;
      for (var i2 = 0; i2 < prices.length; i2++) {
        if (prices[i2][0] === ts) {
          priceAtTs = prices[i2][1];
          break;
        }
        if (prices[i2][0] > ts) {
          if (i2 > 0) {
            if (prices[i2 - 1][0] < ts) {
              priceAtTs = prices[i2][1];
              break;
            }
          } else {
            priceAtTs = prices[i2][1];
            break;
          }
        }
      }
      var anno = {
        text: label,
        point: {
          xAxis: 0,
          yAxis: 0,
          x: ts,
          y: priceAtTs
        }
      };
      annoList.labels.push(anno);
    }
  }
  return annoList;
}
function getData(cin, isSmall, isMedium, avginput, callback) {
  var c = cin === 'popup' ? $('#GEchartpopup').attr('data-chartid') : cin,
    $dataPrices = $('#GEdataprices' + c),
    dataItem = $dataPrices.attr('data-item'),
    dataItemId = $dataPrices.attr('data-itemId') || 'GE ' + dataItem,
    isIndexChart = /index/i.test(dataItem),
    itemName = dataItem || conf.wgTitle.split('/')[0],
    ch = _GEC['chart' + c],
    chartLoaded = !!(ch && ch.series && ch.series.length),
    prices = [],
    i,
    data = [],
    thisprice,
    volumes = [],
    dataList,
    inputAvg,
    newhash,
    yAxis,
    chartPageData;

  // happens when the first chart isSmall
  // and the average input id is actually the popup chart
  // the chart's id is popup, but the input's id is 0
  avginput = avginput || cin;
  var pricesToDataList = function pricesToDataList(inputData) {
    var prices = inputData[0],
      annoData = inputData[1];
    _GEC.urlCache[url] = prices;
    _GEC.urlCache[annoUrl] = annoData;
    prices = Object.values(prices)[0];
    var volumeMultiplier = 1; //isOSRS ? 1 : 1000000
    for (i = 0; i < prices.length; i++) {
      data.push([
      // time
      prices[i][0],
      // @todo should this be parseInt?
      // price
      prices[i][1]]);
      if (prices[i][2] && !isSmall) {
        volumes.push([
        // time
        prices[i][0],
        // volume
        // volumes are in millions
        prices[i][2] * volumeMultiplier]);
      }
    }

    // datalist's elements are essentially each line on the chart
    // so price, 30-day-average and volume
    dataList = [{
      name: itemName,
      data: data,
      lineWidth: isSmall ? 2 : 3
    }];
    if (itemName.toLowerCase() === 'blank' && !chartLoaded) {
      dataList[0].color = '#000000';
    }
    if (!isSmall && !isMedium && (itemName.toLowerCase() !== 'blank' || chartLoaded)) {
      inputAvg = $('#average' + avginput).data('ooui-elem').getNumericValue();

      // @todo should this be isNaN?
      if (inputAvg) {
        newhash = location.hash.replace(/#a=[^#]*|$/, '#a=' + inputAvg).replace(/ /g, '_');
        if (newhash.length) {
          location.hash = newhash;
        }
      }
      inputAvg = inputAvg || 30;
      dataList.push({
        name: inputAvg + '-day average',
        data: util.avg(data, inputAvg, isIndexChart ? 2 : 0),
        lineWidth: 2,
        dashStyle: 'shortdash'
      });
      if (volumes.length >= 10) {
        dataList.push({
          name: volumeLabel,
          data: volumes,
          type: 'area',
          color: '#cc8400',
          fillColor: {
            linearGradient: {
              x1: 0,
              y1: 0,
              x2: 0,
              y2: 1
            },
            stops: [[0, '#ffa500'], [1, 'white']]
          },
          // display on separate y-axis
          yAxis: 1
        });
      }
    }

    // create y-axis for price data
    yAxis = {
      title: {
        text: isSmall ? null : isIndexChart ? 'Index history' : 'Price history',
        offset: 60,
        rotation: 270,
        style: {
          color: 'black',
          fontSize: '12px'
        }
      },
      opposite: false,
      labels: {
        align: 'right',
        x: -8,
        y: 4
      },
      allowDecimals: false,
      // 1 coin
      minTickInterval: 1,
      showLastLabel: 1,
      lineWidth: 1,
      lineColor: '#E0E0E0'
    };

    // volume data is plotted on a seperate y-axis
    if (volumes.length >= 10 && !isSmall) {
      // set height to allow room for second y-axis
      yAxis.height = 200;

      // convert to array and add volume data
      yAxis = [yAxis, {
        title: {
          text: volumeLabel,
          offset: 60,
          rotation: 270,
          style: {
            color: 'black',
            fontSize: '12px'
          }
        },
        opposite: false,
        labels: {
          align: 'right',
          x: -8,
          y: 4
        },
        showEmpty: 0,
        showLastLabel: 1,
        offset: 0,
        lineWidth: 1,
        lineColor: '#E0E0E0',
        height: 50,
        top: 325,
        min: 0
      }];
    }
    var annoList = {};
    if (!isSmall) {
      annoList = formatAnnoList(annoData, prices);
    }
    return [dataList, yAxis, annoList];
  };
  var isPopup = !isSmall && !isMedium;
  var dataType = isPopup ? 'all' : 'sample';
  var url = "https://api.weirdgloop.org/exchange/history/" + gameVersion + "/" + dataType + "?compress=true&id=" + dataItemId;
  var annoUrl = mw.util.getUrl('Module:Exchange/' + itemName + '/annotations.json', {
    action: 'raw',
    'ctype': 'application/json'
  });
  var pricesPromise;
  if (chartLoaded && itemName.toLowerCase() === 'blank') {
    chartPageData = _GEC['chart' + c].series[getSeriesIndex(c, $('#addedItems' + c).find('a').data('item'))];
    for (i = 0; i < chartPageData.xData.length; i++) {
      prices.push(chartPageData.xData[i] + ':' + chartPageData.yData[i]);
    }
    pricesPromise = Promise.resolve(prices);
  } else {
    if (_GEC.urlCache[url]) {
      return callback(pricesToDataList([_GEC.urlCache[url], _GEC.urlCache[annoUrl]]));
    }
    Promise.all([
    // combine these two calls
    // this fetches the price data from the API
    // and the annotation data from the wiki
    // the wiki annotation page may not exist, which returns the second part of the then
    $.getJSON(url), $.getJSON(annoUrl).then(function (d) {
      return d;
    }, function () {
      return [];
    }) //on exception, just use an empty list
    ]).then(pricesToDataList).then(callback);
  }
}
$(self.deps);