Greasy Fork 还支持 简体中文。

repmastered.app winrate sort

Improves matchup tables with sorting and grouping data

  1. // ==UserScript==
  2. // @name repmastered.app winrate sort
  3. // @description Improves matchup tables with sorting and grouping data
  4. // @namespace https://github.com/T1mL3arn
  5. // @version 1.0.3
  6. // @match https://repmastered.app/map/*
  7. // @grant none
  8. // @author T1mL3arn
  9. // @run-at document-end
  10. // @require https://code.jquery.com/jquery-3.5.1.min.js
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/datatables/1.10.21/js/jquery.dataTables.min.js
  12. // @license WTFPL 2
  13. // @icon https://repmastered.app/static/img/favicon.ico
  14. // @homepageURL https://github.com/t1ml3arn-userscript-js/repmastered.app-winrate-sorting
  15. // @supportURL https://github.com/t1ml3arn-userscript-js/repmastered.app-winrate-sorting/issues
  16. // ==/UserScript==
  17.  
  18. // add DataTables CSS
  19. const link = document.createElement('link')
  20. link.rel = "stylesheet"
  21. link.type = "text/css"
  22. link.href = "https://cdn.datatables.net/v/dt/dt-1.10.21/datatables.min.css"
  23. link.onload = _ => run()
  24.  
  25. document.head.appendChild(link)
  26.  
  27. // ----------------------------
  28.  
  29. class query {
  30.  
  31. constructor() {
  32. this.result = []
  33. }
  34.  
  35. from(data) {
  36. this.result = data.slice()
  37. return this
  38. }
  39.  
  40. where(filter) {
  41. this.result = this.result.filter(filter)
  42. return this
  43. }
  44.  
  45. /**
  46. * Group data (by only 1 column !!!)
  47. */
  48. groupBy(func) {
  49. // get all unique groups
  50. const groups = new Set(this.result.map(row => func(row)))
  51. // collect all values for all groups
  52. this.result = [...groups].map(gr => [gr, this.result.filter(i => func(i) == gr)])
  53. // the result now looks like:
  54. /*
  55. [
  56. [group_1, [ row_1, row_2, ...]],
  57. [group_2, [ row_3, row_5, ...]],
  58. ...
  59. ]
  60. */
  61. return this
  62. }
  63.  
  64. aggregate(targetCol, func, colAlias = null) {
  65. this.result.forEach(row => {
  66. const groupData = row[1]
  67. const dateToAggreagate = groupData.map(obj => obj[targetCol])
  68. const result = func(dateToAggreagate)
  69.  
  70. row.push({ alias: colAlias || targetCol, value: result })
  71. })
  72.  
  73. /* now results look like this
  74. [
  75. [group_1, [ row_1, row_2, ...], { alias: alias_1, value: gr_1_aggr_result }, { }, ... ],
  76. [group_2, [ row_3, row_5, ...], { alias: alias_1, value: gr_2_aggr_result }, { }, ... ],
  77. ...
  78. ]
  79. */
  80.  
  81. return this
  82. }
  83. }
  84.  
  85. /**
  86. * Add <thead> if a table misses it
  87. */
  88. function addThead($table) {
  89. $table.not(":has(thead)") // get tables without <thead>
  90. .each((i, t) => {
  91. $(t).find("tr:first-child") // lets suppose the first <tr> is <thead>
  92. .wrap("<thead>") // wrap it with header
  93. .parent() // then get this header
  94. .remove() // remove the header
  95. .prependTo(t) // and add the header into beginning of original table
  96. })
  97.  
  98. return $table
  99. }
  100.  
  101. /** Creates textual tag for given elt.
  102. * The text then should be passed into jquery
  103. * to create DOM element. */
  104. function ce(elt) {
  105. return `<${elt}></${elt}>`
  106. }
  107.  
  108. // ----------------------------
  109.  
  110. // CSS fixes
  111. // NOTE: DataTables CSS interferes with repmastered CSS,
  112. // so it should be fixed
  113.  
  114. const STATS_TABLE_CLASS = 'stats-tbl'
  115.  
  116. const CSS_FIX = `
  117. .${STATS_TABLE_CLASS} {
  118. border-collapse: collapse !important;
  119. }
  120. .${STATS_TABLE_CLASS} td, .${STATS_TABLE_CLASS} th {
  121. padding: 3px !important;
  122. }
  123.  
  124. .${STATS_TABLE_CLASS} th {
  125. padding-right: 8px !important;
  126. position: unset !important;
  127. }
  128.  
  129. .${STATS_TABLE_CLASS} td {
  130. text-align: center;
  131. }
  132.  
  133. .${STATS_TABLE_CLASS} tr:first-child th[colspan='1'][rowspan='1'] {
  134. height: 2.25em;
  135. }
  136.  
  137. .${STATS_TABLE_CLASS} thead {
  138. position: sticky;
  139. top: 0;
  140.  
  141. /* this fixes repmastered.app arrows visibility
  142. over a table header */
  143. z-index: 1;
  144. }
  145.  
  146. /* row striping */
  147. .${STATS_TABLE_CLASS} tr:nth-child(even) { background-color: #fff !important }
  148. .${STATS_TABLE_CLASS} tr:nth-child(odd) { background-color: #fff3cf !important }
  149. .${STATS_TABLE_CLASS} tr:hover { background-color: #ddf !important }
  150.  
  151. .dataTables_wrapper.hidden { display: none; }
  152.  
  153. .text--hint { color: #777; font-style: italic; font-size: 0.9em; }
  154.  
  155. .winrate-tbl-menu { margin-top: 1em; }
  156.  
  157. .matchup-details {
  158. border-top: 1px solid #ccc;
  159. margin-top: 2em;
  160. margin-bottom: 1em;
  161. }
  162.  
  163. td.no-after::after {
  164. content: '';
  165. }
  166. `
  167.  
  168. $('<style></style>').attr('id', 'sort-stats-css-fix').text(CSS_FIX).appendTo('head')
  169.  
  170. /**
  171. * Fixes css for initialized(!) DataTables.
  172. * @param {jQuery} $target jQeury object (list of tables)
  173. */
  174. function fixCss($target, width = '80%') {
  175. $target.addClass(['display'])
  176. $target.parent().css('width', width)
  177. $target.parent().find('.dataTables_filter').css('margin-bottom', '.5em')
  178. return $target
  179. }
  180.  
  181. /** Removes markup from text extracted from matchup coulumn */
  182. function getMatchup(txt) {
  183. txt = txt.slice(txt.indexOf('>')+1)
  184. return txt.slice(0, txt.indexOf('<'))
  185. }
  186.  
  187. /**
  188. * Fills background of a given cell with linear gradient.
  189. * @param {jQuery} cell table cell (jquery object)
  190. * @param {Number} fill Percent value for linear-gradient()
  191. */
  192. function addProgressBar(cell, fill = 0) {
  193. cell.css('background', `linear-gradient(to right,#fd0 ${fill}%,#ccc ${fill}%)`)
  194. }
  195.  
  196. /**
  197. * Creates menu to control what table to show -
  198. * detailed stats or grouped by race composition.
  199. * @param {jQuery} srcTable Source detailed table (jquery DOM object)
  200. * @param {jQuery} groupTableWrap Grouped table's wrapper (jquery DOM object)
  201. */
  202. function createGroupCtrlMenu(srcTable, groupTableWrap) {
  203.  
  204. const check = $(ce('input')).attr({type: "checkbox"}).get(0)
  205. check.dataset.srcId = srcTable.parent().attr('id')
  206. check.dataset.targetId = groupTableWrap.attr('id')
  207. $(check).change(e => {
  208. srcTable.parent().toggleClass('hidden')
  209. groupTableWrap.toggleClass('hidden')
  210. })
  211.  
  212. const div = $(ce('div')).addClass('winrate-tbl-menu')
  213. div.insertBefore(srcTable.parent())
  214. $(ce('label')).append(check)
  215. .append($(ce('span')).text('Group by race combination'))
  216. .appendTo(div)
  217.  
  218. $(ce('p')).text('NOTE: Grouped data exclude mirror matchups')
  219. .addClass('text--hint')
  220. .appendTo(div)
  221.  
  222. $(ce('p')).text('HINT: shift-click a column for multiple-column ordering')
  223. .addClass('text--hint')
  224. .appendTo(div)
  225. }
  226.  
  227. /** Mimics original popup behavior when a user clicks on a matchup cell */
  228. function showMatchup2Popup(e) {
  229. // save original text
  230. const cell = e.currentTarget
  231. const srcText = cell.textContent
  232. // restore full matchup name
  233. cell.textContent = $(cell).parent().prev().text() + 'v' + srcText
  234. // call method how it should be called
  235. showPopup('matchup2', cell)
  236. // restore original text
  237. cell.textContent = srcText
  238. }
  239.  
  240. /** Add shared title attribute to both matchup cells
  241. * (they were splitted before) */
  242. function setMatchupTitle(td, d, row) {
  243. $(td).attr('title', `${row[0]}v${getMatchup(row[1])}`)
  244. }
  245.  
  246. // ----------------------------
  247.  
  248. function run() {
  249.  
  250. // VM tells me @require scripts are executed before the script itself
  251. // and also the script executed on "document-end" event
  252. // so it should be safe to just use jquery and the rest.
  253.  
  254. // set ids to matchup tables
  255. $('h3').filter((i, elt) => {
  256. const match = elt.textContent.match(/(\d)v\d\smatchups/i)
  257. if (match) {
  258. const num = match[1]
  259. // new id for a table
  260. // looks like "v11" or "v44" etc
  261. const id = 'v' + num + num
  262.  
  263. // find the <table> (it is sibling with <h3> parrent elt - <summary>)
  264. // and set its new id
  265. $(elt.parentNode).find('+ table')
  266. .attr('id', id)
  267. .addClass(STATS_TABLE_CLASS)
  268. }
  269. })
  270.  
  271. const TBL_SELECTOR = '.'+STATS_TABLE_CLASS
  272.  
  273. // DataTables lib demands <thead> for <table>
  274. addThead($(TBL_SELECTOR))
  275.  
  276. // remove first column with row number
  277. $(TBL_SELECTOR).find('th:first-child, td:first-child').remove()
  278. // delete DOWN arrow
  279. $(TBL_SELECTOR).find('thead').find('th:contains("Games ↓")').text('Games')
  280. // for tables all except 1v1
  281. $(TBL_SELECTOR).not('#v11').each((i, tbl) => {
  282.  
  283. // split matchup into 2 columns
  284. $(tbl).find('tbody tr td:first-child')
  285. .each((i, td) => {
  286. const matchup = $(td).text().split('v')
  287. $(ce('td')).text(matchup[0]).insertBefore(td)
  288. $(td).find('span')
  289. .text(matchup[1])
  290. .attr('onclick', '')
  291. .click(showMatchup2Popup)
  292. })
  293. // extend table headers after matchup spliting
  294. // see example for colspan/rowspan there - https://jsfiddle.net/qgk5twdo/
  295. $(tbl).find('thead th:first-child').attr('colspan', 2)
  296. $(tbl).find('thead th:not(:first-child)').attr('rowspan', 2)
  297. $(tbl).find('thead').append('<tr></tr>')
  298. .find('tr:last-child')
  299. .append('<th>race</th>')
  300. .append('<th>race</th>')
  301. })
  302.  
  303. // init tables as DataTables
  304. const initv11 = {
  305. paging: false,
  306. order: [[4, "desc"]],
  307. orderMulti: true,
  308. columnDefs: [
  309. // disable ordering for some columns
  310. { orderable: false, targets: [3, 8, 9] }
  311. ],
  312. autoWidth: false,
  313. }
  314. $('#v11').DataTable(initv11)
  315.  
  316. const initArgs = {
  317. paging: false,
  318. order: [[5, "desc"]],
  319. orderMulti: true,
  320. columnDefs: [
  321. // disable ordering for some columns
  322. { orderable: false, targets: [4, 9, 10] },
  323. { createdCell: setMatchupTitle, targets: [0, 1] },
  324. ],
  325. autoWidth: false,
  326. }
  327. $('#v22, #v33, #v44').DataTable(initArgs)
  328.  
  329. // ----------------------------
  330.  
  331. // apply CSS fixes
  332. fixCss($(TBL_SELECTOR))
  333. $(TBL_SELECTOR).each((i, tbl) => {
  334. const id = tbl.id
  335. $(tbl).parent().parent()
  336. .addClass('matchup-details')
  337. .attr('id', `${id}-details`)
  338. })
  339. $(TBL_SELECTOR).not('#v11').find('tbody tr')
  340. .find('td:first-child, td:nth-child(2)')
  341. .addClass('no-after')
  342.  
  343. // ----------------------------
  344.  
  345. // build groupped data
  346.  
  347. function split_1v1_race(data) {
  348. return data.map(row => {
  349. const split = row[0].split('v')
  350. return [...split, ...row.slice(1)]
  351. })
  352. }
  353.  
  354. function duplicateMatchupRows(data) {
  355. return data.concat(data.map(row => {
  356. const newRow = row.slice()
  357. newRow[5] = 100 - parseInt(newRow[5])
  358. newRow[0] = row[1]
  359. newRow[1] = row[0]
  360. return newRow
  361. }));
  362. }
  363.  
  364. const rawData = [];
  365.  
  366. (function(){
  367. let data = $('#v11').DataTable().rows().data().toArray()
  368. data.forEach( row => row[0] = getMatchup(row[0]) )
  369. data = split_1v1_race(data)
  370. data = duplicateMatchupRows(data)
  371. rawData.push(data)
  372. })();
  373.  
  374. $('#v22, #v33, #v44').each((i, tbl) => {
  375. let data = $(tbl).DataTable().rows().data().toArray()
  376. data.forEach( row => row[1] = getMatchup(row[1]) )
  377. // duplicate data to get all race combinations
  378. data = duplicateMatchupRows(data)
  379. rawData.push(data)
  380. })
  381.  
  382. // filter, grouping and aggregates
  383. const notMirror = row => row[0] != row[1] ;
  384. const matchupGroup = row => row[0]
  385. const sum = value => value.reduce((acc, cur) => acc + parseInt(cur), 0)
  386. const avg = value => sum(value) / value.length
  387. const minStr = value => value.reduce((acc, curr) => curr < acc ? curr: acc)
  388. const maxStr = value => value.reduce((acc, curr) => curr > acc ? curr: acc)
  389.  
  390. const groupData = rawData.map(rows => {
  391. return new query().from(rows)
  392. .where(notMirror)
  393. .groupBy(matchupGroup)
  394. .aggregate(5, avg, 'winrate')
  395. .aggregate(2, sum, 'num games')
  396. .aggregate(3, sum, 'num games %')
  397. .aggregate(7, minStr, 'first game')
  398. .aggregate(8, maxStr, 'last game')
  399. .result;
  400. })
  401.  
  402. // console.log(groupData);
  403.  
  404. /**
  405. * Creates race composition winrate table.
  406. * Returns jQuery object
  407. */
  408. function createRCWTable(){
  409. return $(ce('table')).append(ce('thead'))
  410. .find('thead').append(ce('tr'))
  411. .find('tr')
  412. .append($(ce('th')).text('Race').attr('title', 'Race composition'))
  413. .append($(ce('th')).text('Winrate %'))
  414. .append($(ce('th')).text('Games'))
  415. .append($(ce('th')).text('Games %'))
  416. .append($(ce('th')).text('First Game'))
  417. .append($(ce('th')).text('Last Game'))
  418. .parent() // back to <thead>
  419. .parent() // back to <table>
  420. .addClass(STATS_TABLE_CLASS)
  421. }
  422.  
  423. $('#v11, #v22, #v33, #v44').each((i, srcTable) => {
  424. // groupData[i] is an array of rows with aggregate results
  425. const data = groupData[i].map(row => {
  426. return [
  427. row[0], // race composition
  428. row[2].value, // winrate
  429. row[3].value, // games
  430. row[4].value, // games %
  431. row[5].value, // first game
  432. row[6].value, // last game
  433. ];
  434. })
  435.  
  436. // calc "Games %" properly
  437. const sumGames = data.reduce((acc, row) => acc + row[2], 0)
  438. data.forEach(row => row[3] = (row[2] * 100) / sumGames )
  439.  
  440. const initArgs = {
  441. paging: false,
  442. data: data,
  443. order: [[1, "desc"]],
  444. orderMulti: true,
  445. autoWidth: false,
  446. columnDefs: [ {
  447. // render percent symbol in "Games %" column
  448. targets: 3,
  449. render: val => String(Math.round(val)) + '%'
  450. }, {
  451. // render percent symbol in "Winrate %" column
  452. targets: 1,
  453. render: val => String(Math.round(val)) + '%'
  454. } ],
  455. }
  456.  
  457. const tbl = createRCWTable()
  458. tbl.DataTable(initArgs)
  459.  
  460. const tblWrap = tbl.DataTable().table().container()
  461.  
  462. // such tables are hidden by default
  463. $(tblWrap).addClass('hidden')
  464.  
  465. fixCss(tbl, '60%')
  466. // add progress bar bg for "games" and "winrate" columns
  467. tbl.find('tbody td:nth-child(2), tbody td:nth-child(4)')
  468. .each((i, td) => addProgressBar($(td), parseFloat($(td).text())) );
  469.  
  470. // place group table after coresponding initial table
  471. $(srcTable).parent().after(tblWrap)
  472.  
  473. createGroupCtrlMenu($(srcTable), $(tblWrap))
  474. })
  475.  
  476. }