// ==UserScript==
// @name repmastered.app winrate sort
// @description Improves matchup tables with sorting and grouping data
// @namespace https://github.com/T1mL3arn
// @version 1.0.3
// @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/jquery.dataTables.min.js
// @license WTFPL 2
// @icon https://repmastered.app/static/img/favicon.ico
// @homepageURL https://github.com/t1ml3arn-userscript-js/repmastered.app-winrate-sorting
// @supportURL https://github.com/t1ml3arn-userscript-js/repmastered.app-winrate-sorting/issues
// ==/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;
}
td.no-after::after {
content: '';
}
`
$('<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 matchup 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
}
/** Add shared title attribute to both matchup cells
* (they were splitted before) */
function setMatchupTitle(td, d, row) {
$(td).attr('title', `${row[0]}v${getMatchup(row[1])}`)
}
// ----------------------------
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] },
{ createdCell: setMatchupTitle, targets: [0, 1] },
],
autoWidth: false,
}
$('#v22, #v33, #v44').DataTable(initArgs)
// ----------------------------
// apply CSS fixes
fixCss($(TBL_SELECTOR))
$(TBL_SELECTOR).each((i, tbl) => {
const id = tbl.id
$(tbl).parent().parent()
.addClass('matchup-details')
.attr('id', `${id}-details`)
})
$(TBL_SELECTOR).not('#v11').find('tbody tr')
.find('td:first-child, td:nth-child(2)')
.addClass('no-after')
// ----------------------------
// 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))
})
}