repmastered.app winrate sort

Improves matchup tables with sorting and grouping data

As of 2020-12-04. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        repmastered.app winrate sort
// @description Improves matchup tables with sorting and grouping data
// @namespace   https://github.com/T1mL3arn
// @version     1.0.0
// @match       https://repmastered.app/map/*
// @grant       none
// @author      T1mL3arn
// @run-at      document-end
// @require     https://code.jquery.com/jquery-3.5.1.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/datatables/1.10.21/js/dataTables.dataTables.min.js
// @license     WTFPL 2
// ==/UserScript==

// add DataTables CSS
const link = document.createElement('link')
link.rel = "stylesheet"
link.type = "text/css"
link.href = "https://cdn.datatables.net/v/dt/dt-1.10.21/datatables.min.css"
link.onload = _ => run()

document.head.appendChild(link)

// ----------------------------

class query {

    constructor() {
        this.result = []
    }

    from(data) {
        this.result = data.slice()
        return this
    }

    where(filter) {
        this.result = this.result.filter(filter)
        return this
    }

    /**
     * Group data (by only 1 column !!!)
    */
    groupBy(func) {
        // get all unique groups
        const groups = new Set(this.result.map(row => func(row)))
        // collect all values for all groups
        this.result = [...groups].map(gr => [gr, this.result.filter(i => func(i) == gr)])
        // the result now looks like:
        /*
        [
          [group_1, [ row_1, row_2, ...]],
          [group_2, [ row_3, row_5, ...]],
          ...
        ]
        */
        return this
    }

    aggregate(targetCol, func, colAlias = null) {
        this.result.forEach(row => {
            const groupData = row[1]
            const dateToAggreagate = groupData.map(obj => obj[targetCol])
            const result = func(dateToAggreagate)

            row.push({ alias: colAlias || targetCol, value: result })
        })

        /*  now results look like this
        [
          [group_1, [ row_1, row_2, ...], { alias: alias_1, value: gr_1_aggr_result }, { }, ... ],
          [group_2, [ row_3, row_5, ...], { alias: alias_1, value: gr_2_aggr_result }, { }, ... ],
          ...
        ] 
        */

        return this
    }
}

/**
 * Add <thead> if a table misses it
*/
function addThead($table) {
    $table.not(":has(thead)")     // get tables without <thead>
        .each((i, t) => {
            $(t).find("tr:first-child")   // lets suppose the first <tr> is <thead>
                .wrap("<thead>")          // wrap it with header 
                .parent()                 // then get this header
                .remove()                 // remove the header
                .prependTo(t)             // and add the header into beginning of original table
        })

    return $table
}

/** Creates textual tag for given elt. 
 * The text then should be passed into jquery 
 * to create DOM element. */
function ce(elt) {
    return `<${elt}></${elt}>`
}

// ----------------------------

// CSS fixes
// NOTE: DataTables CSS interferes with repmastered CSS,
// so it should be fixed

const STATS_TABLE_CLASS = 'stats-tbl'

const CSS_FIX = `
    
    .${STATS_TABLE_CLASS} {
        border-collapse: collapse !important;
    }
    
    .${STATS_TABLE_CLASS} td, .${STATS_TABLE_CLASS} th {
        padding: 3px !important;
    }

    .${STATS_TABLE_CLASS} th {
        padding-right: 8px !important;
        position: unset !important;
    }

    .${STATS_TABLE_CLASS} td {
        text-align: center;
    }

    .${STATS_TABLE_CLASS} tr:first-child th[colspan='1'][rowspan='1'] {
        height: 2.25em;
    }

    .${STATS_TABLE_CLASS} thead {
        position: sticky;
        top: 0;

        /* this fixes repmastered.app arrows visibility 
            over a table header */
        z-index: 1;
    }

    /* row striping */
    .${STATS_TABLE_CLASS} tr:nth-child(even) { background-color: #fff !important }
    .${STATS_TABLE_CLASS} tr:nth-child(odd) { background-color: #fff3cf !important }
    .${STATS_TABLE_CLASS} tr:hover { background-color: #ddf !important }

    .dataTables_wrapper.hidden { display: none; }

    .text--hint { color: #777; font-style: italic; font-size: 0.9em; }

    .winrate-tbl-menu { margin-top: 1em; }

    .matchup-details { 
        border-top: 1px solid #ccc; 
        margin-top: 2em;
        margin-bottom: 1em;
    }
`

$('<style></style>').attr('id', 'sort-stats-css-fix').text(CSS_FIX).appendTo('head')

/**
 * Fixes css for initialized(!) DataTables.
 * @param {jQuery} $target jQeury object (list of tables)
 */
function fixCss($target, width = '80%') {
    $target.addClass(['display'])
    $target.parent().css('width', width)
    $target.parent().find('.dataTables_filter').css('margin-bottom', '.5em')
    return $target
}

/** Removes markup from text extracted from mathup coulumn */
function getMatchup(txt) {
    txt = txt.slice(txt.indexOf('>')+1)
    return txt.slice(0, txt.indexOf('<'))
}

/**
 * Fills background of a given cell with linear gradient.
 * @param {jQuery} cell table cell (jquery object)
 * @param {Number} fill Percent value for linear-gradient()
 */
function addProgressBar(cell, fill = 0) {
    cell.css('background', `linear-gradient(to right,#fd0 ${fill}%,#ccc ${fill}%)`)
}

/** 
 * Creates menu to control what table to show - 
 * detailed stats or grouped by race composition.
 * @param {jQuery} srcTable Source detailed table (jquery DOM object)
 * @param {jQuery} groupTableWrap Grouped table's wrapper (jquery DOM object)
*/
function createGroupCtrlMenu(srcTable, groupTableWrap) {

    const check = $(ce('input')).attr({type: "checkbox"}).get(0)
    check.dataset.srcId = srcTable.parent().attr('id')
    check.dataset.targetId = groupTableWrap.attr('id')
    $(check).change(e => {
        srcTable.parent().toggleClass('hidden')
        groupTableWrap.toggleClass('hidden')
    })

    const div = $(ce('div')).addClass('winrate-tbl-menu')
    div.insertBefore(srcTable.parent())
    
    $(ce('label')).append(check)
        .append($(ce('span')).text('Group by race combination'))
        .appendTo(div)

    $(ce('p')).text('NOTE: Grouped data exclude mirror matchups')
        .addClass('text--hint')
        .appendTo(div)

    $(ce('p')).text('HINT: shift-click a column for multiple-column ordering')
        .addClass('text--hint')
        .appendTo(div)
}

/** Mimics original popup behavior when a user clicks on a matchup cell */
function showMatchup2Popup(e) {
    // save original text
    const cell = e.currentTarget
    const srcText = cell.textContent
    
    // restore full matchup name
    cell.textContent = $(cell).parent().prev().text() + 'v' + srcText
    
    // call method how it should be called
    showPopup('matchup2', cell)
    
    // restore original text
    cell.textContent = srcText
}

// ----------------------------

function run() {

// VM tells me @require scripts are executed before the script itself
// and also the script executed on "document-end" event
// so it should be safe to just use jquery and the rest.

// set ids to matchup tables
$('h3').filter((i, elt) => {
    const match = elt.textContent.match(/(\d)v\d\smatchups/i)
    if (match) {
        const num = match[1]
        // new id for a table
        // looks like "v11" or "v44" etc
        const id = 'v' + num + num

        // find the <table> (it is sibling with <h3> parrent elt - <summary>)
        // and set its new id
        $(elt.parentNode).find('+ table')
            .attr('id', id)
            .addClass(STATS_TABLE_CLASS)
    }
})

const TBL_SELECTOR = '.'+STATS_TABLE_CLASS

// DataTables lib demands <thead> for <table>
addThead($(TBL_SELECTOR))

// remove first column with row number
$(TBL_SELECTOR).find('th:first-child, td:first-child').remove()
// delete DOWN arrow
$(TBL_SELECTOR).find('thead').find('th:contains("Games ↓")').text('Games')
// for tables all except 1v1
$(TBL_SELECTOR).not('#v11').each((i, tbl) => {

    // split matchup into 2 columns 
    $(tbl).find('tbody tr td:first-child')
        .each((i, td) => {
            const matchup = $(td).text().split('v')
            $(ce('td')).text(matchup[0]).insertBefore(td)
            $(td).find('span')
                .text(matchup[1])
                .attr('onclick', '')
                .click(showMatchup2Popup)
        })
    
    // extend table headers after matchup spliting
    // see example for colspan/rowspan there - https://jsfiddle.net/qgk5twdo/
    $(tbl).find('thead th:first-child').attr('colspan', 2)
    $(tbl).find('thead th:not(:first-child)').attr('rowspan', 2)
    $(tbl).find('thead').append('<tr></tr>')
        .find('tr:last-child')
        .append('<th>race</th>')
        .append('<th>race</th>')
})

// init tables as DataTables
const initv11 = {
    paging: false,
    order: [[4, "desc"]],
    orderMulti: true,
    columnDefs: [
        // disable ordering for some columns
        { orderable: false, targets: [3, 8, 9] }
    ],
    autoWidth: false,
}
$('#v11').DataTable(initv11)

const initArgs = {
    paging: false,
    order: [[5, "desc"]],
    orderMulti: true,
    columnDefs: [
        // disable ordering for some columns
        { orderable: false, targets: [4, 9, 10] }
    ],
    autoWidth: false,
}
$('#v22, #v33, #v44').DataTable(initArgs)

// ----------------------------

// apply CSS fixes
fixCss($(TBL_SELECTOR))
$(TBL_SELECTOR).parent().parent().addClass('matchup-details')

// ----------------------------

// build groupped data

function split_1v1_race(data) {
    return data.map(row => {
        const split = row[0].split('v')
        return [...split, ...row.slice(1)]
    })
}

function duplicateMatchupRows(data) {
    return data.concat(data.map(row => {
        const newRow = row.slice()
        newRow[5] = 100 - parseInt(newRow[5])
        newRow[0] = row[1]
        newRow[1] = row[0]
        return newRow
    }));
}

const rawData = [];

(function(){
    let data = $('#v11').DataTable().rows().data().toArray()
    data.forEach( row => row[0] = getMatchup(row[0]) )
    data = split_1v1_race(data)
    data = duplicateMatchupRows(data)
    rawData.push(data)
})();

$('#v22, #v33, #v44').each((i, tbl) => {
    let data = $(tbl).DataTable().rows().data().toArray()
    data.forEach( row => row[1] = getMatchup(row[1]) )
    // duplicate data to get all race combinations
    data = duplicateMatchupRows(data)
    rawData.push(data)
})

// filter, grouping and aggregates
const notMirror = row => row[0] != row[1] ;
const matchupGroup = row => row[0]
const sum = value => value.reduce((acc, cur) => acc + parseInt(cur), 0)
const avg = value => sum(value) / value.length
const minStr = value => value.reduce((acc, curr) => curr < acc ? curr: acc)
const maxStr = value => value.reduce((acc, curr) => curr > acc ? curr: acc)

const groupData = rawData.map(rows => {
    return new query().from(rows)
        .where(notMirror)
        .groupBy(matchupGroup)
        .aggregate(5, avg, 'winrate')
        .aggregate(2, sum, 'num games')
        .aggregate(3, sum, 'num games %')
        .aggregate(7, minStr, 'first game')
        .aggregate(8, maxStr, 'last game')
        .result;
})

// console.log(groupData);

/**
 * Creates race composition winrate table.
 * Returns jQuery object
 */
function createRCWTable(){
    return $(ce('table')).append(ce('thead'))
        .find('thead').append(ce('tr'))
        .find('tr')
            .append($(ce('th')).text('Race').attr('title', 'Race composition'))
            .append($(ce('th')).text('Winrate %'))
            .append($(ce('th')).text('Games'))
            .append($(ce('th')).text('Games %'))
            .append($(ce('th')).text('First Game'))
            .append($(ce('th')).text('Last Game'))
        .parent()   // back to <thead>
        .parent()   // back to <table>
        .addClass(STATS_TABLE_CLASS)
}

$('#v11, #v22, #v33, #v44').each((i, srcTable) => {
    // groupData[i] is an array of rows with aggregate results
    const data = groupData[i].map(row => {
        return [
            row[0],         // race composition
            row[2].value,   // winrate
            row[3].value,   // games
            row[4].value,   // games %
            row[5].value,   // first game
            row[6].value,   // last game
        ];
    })

    // calc "Games %" properly
    const sumGames = data.reduce((acc, row) => acc + row[2], 0)
    data.forEach(row => row[3] = (row[2] * 100) / sumGames )

    const initArgs = {
        paging: false,
        data: data,
        order: [[1, "desc"]],
        orderMulti: true,
        autoWidth: false,
        columnDefs: [ {
            // render percent symbol in "Games %" column
            targets: 3,
            render: val => String(Math.round(val)) + '%'
        }, {
            // render percent symbol in "Winrate %" column
            targets: 1,
            render: val => String(Math.round(val)) + '%'
        } ],
    }

    const tbl = createRCWTable()
    tbl.DataTable(initArgs)

    const tblWrap = tbl.DataTable().table().container()

    // such tables are hidden by default
    $(tblWrap).addClass('hidden')

    fixCss(tbl, '60%')
    
    // add progress bar bg for "games" and "winrate" columns
    tbl.find('tbody td:nth-child(2), tbody td:nth-child(4)')
        .each((i, td) => addProgressBar($(td), parseFloat($(td).text())) );

    // place group table after coresponding initial table
    $(srcTable).parent().after(tblWrap)

    createGroupCtrlMenu($(srcTable), $(tblWrap))
})

}