// ==UserScript==
// @name AtCoderScoreHistogram
// @namespace http://tampermonkey.net/
// @version 1.0.1
// @description AtCoderの順位表ページに得点分布を表すヒストグラムを追加します。
// @author hyyk
// @match https://atcoder.jp/contests/*/standings*
// @require https://cdnjs.cloudflare.com/ajax/libs/plotly.js/2.20.0/plotly-cartesian.min.js
// @exclude https://atcoder.jp/contests/*/standings/json
// @grant none
// @license MIT
// ==/UserScript==
$(function () {
'use strict';
window.addEventListener('load', function () {
vueStandings.$watch('standings', function (newStandings, old) {
if (!newStandings) {
return;
}
if (old === undefined || old == null) {
setupInterface();
} else {
const activeTask = document.querySelector('li.tab.active');
if (activeTask !== null) {
const taskScreenName = activeTask.getAttribute('value');
const drawHistogramHandler = createDrawHistogramHandler(taskScreenName);
drawHistogramHandler.handleEvent();
}
}
}, { deep: true, immediate: true });
});
});
function processStandings(standings) {
let processedStandings = { TaskInfo: {} };
const taskInfo = standings['TaskInfo'];
const standingsData = standings['StandingsData'];
for (const task of taskInfo) {
const taskScreenName = task['TaskScreenName'];
processedStandings['TaskInfo'][taskScreenName] = task;
processedStandings['TaskInfo'][taskScreenName]['ResultInfo'] = {};
}
for (const user of standingsData) {
const userName = user['UserScreenName'];
for (const [taskScreenName, results] of Object.entries(user['TaskResults'])) {
const score = results['Score'];
processedStandings['TaskInfo'][taskScreenName]['ResultInfo'][userName] = {
'Score': score
};
}
}
return processedStandings;
}
function setupInterface() {
document.getElementById('vue-standings').insertAdjacentHTML('beforebegin', `
<button type="button" id="show-or-hide-hist-btn" class="btn btn-default" style="margin-bottom: 1em;margin-top: 1em;">Show/Hide a histogram</button>
<div id="hist-container" style="margin-bottom: 1em;padding: 1em;background-color: #fff;border: 1px solid #ddd;" hidden>
<ul id="tab-bar" style="border-bottom: 1px solid #ddd;display: flex;flex-wrap: wrap;padding-left: 0px;"></ul>
<div id="hist-heading" style="display: flex;">
<div style="flex: 0.7;">
<label for="usernames-input">Usernames</label>
<input type="text" name="usernames-input" class="form-control" id="user-names" placeholder="username1, username2, ..." style="margin-bottom: 1em;width: 100%">
<button id="hist-update-btn" class="btn btn-primary" style="display: inline;margin-right: 1em;">Update</button>
<p id="invalid-users" style="display: inline; color: red"></p>
</div>
</div>
<div id="hist-area"></div>
</div>
`);
const processedStandings = processStandings(vueStandings.standings);
const tabBar = document.getElementById('tab-bar');
for (const [taskScreenName, info] of Object.entries(processedStandings['TaskInfo'])) {
const assignment = info['Assignment'];
tabBar.insertAdjacentHTML('beforeend', `
<li class="tab"
style="border: 1px solid transparent;list-style: none;margin-bottom: -1px;padding: 0.4em 0.7em;cursor: pointer;color: #337ab7;"
value="${taskScreenName}">${assignment}
</li>
`);
}
document.querySelectorAll('li.tab').forEach((tab) => {
const taskScreenName = tab.getAttribute('value');
const drawHistogramHandler = createDrawHistogramHandler(taskScreenName);
tab.addEventListener('click', tabSwitch);
tab.addEventListener('click', drawHistogramHandler);
});
document.getElementById('hist-update-btn').addEventListener('click', function () {
const activeTab = document.querySelector('li.tab.active')
if (activeTab !== null) {
const taskScreenName = activeTab.getAttribute('value');
const drawHistogramHandler = createDrawHistogramHandler(taskScreenName);
drawHistogramHandler.handleEvent();
}
});
const showOrHideHistogramButton = document.getElementById('show-or-hide-hist-btn');
const histogramContainer = document.getElementById('hist-container');
showOrHideHistogramButton.addEventListener('click', function () {
if (histogramContainer.hasAttribute('hidden') === true) {
const refreshButton = document.getElementById('refresh');
if (refreshButton !== null) {
const histogramHeading = document.getElementById('hist-heading');
let refreshButtonArea = refreshButton.parentNode;
refreshButtonArea.setAttribute('style', 'flex: 0.3; text-align: right;');
refreshButtonArea.querySelector('#last-refresh').setAttribute('style', 'display: block;');
histogramHeading.insertAdjacentElement('beforeend', refreshButtonArea.parentNode.removeChild(refreshButtonArea));
}
histogramContainer.removeAttribute('hidden');
if (tabBar.firstChild !== null) {
tabBar.firstElementChild.click();
}
} else {
histogramContainer.setAttribute('hidden', "");
}
});
}
function createDrawHistogramHandler(taskScreenName) {
return {
taskScreenName: taskScreenName,
histogramArea: 'hist-area',
processedStandings: processStandings(vueStandings.standings),
handleEvent: drawHistogram
};
}
function tabSwitch() {
const activeTab = document.querySelector('li.tab.active')
if (activeTab !== null) {
activeTab.classList.remove('active');
activeTab.setAttribute('style', 'border: 1px solid transparent;list-style: none;margin-bottom: -1px;padding: 0.4em 0.7em;cursor: pointer;color: #337ab7;')
}
this.classList.add('active');
this.setAttribute('style', 'border: 1px solid transparent;list-style: none;margin-bottom: -1px;padding: 0.4em 0.7em;cursor: pointer;color: #337ab7;border-left: 1px solid #ccc;border-right: 1px solid #ccc;border-top: 1px solid #ccc;border-bottom: 1px solid #fff;background-color: #fff;');
}
function drawHistogram() {
const userNames = document.getElementById('user-names').value;
const taskInfo = this.processedStandings['TaskInfo'][this.taskScreenName];
const taskAssignment = taskInfo['Assignment'];
let scores = [];
for (const userName in taskInfo['ResultInfo']) {
scores.push(taskInfo['ResultInfo'][userName]['Score']);
}
const layout = {
font: { size: 14 },
plot_bgcolor: "rgb(250, 250, 250)",
xaxis: {
title: {
text: "Score"
}
},
yaxis: {
title: {
text: "Frequency"
}
},
title: this.histogramArea === 'hist-for-png' ? 'Problem ' + taskAssignment : '',
shapes: [],
autosize: true,
margin: {
t: 30,
},
};
const traceOfScore = {
x: scores.map(x => x / 100),
type: "histogram",
name: "cumulative",
marker: {
color: "#c4ecec",
line: {
color: "#000000",
width: 0.03
}
},
nbinsx: 30
};
const data = [traceOfScore];
const config = { responsive: true };
layout.annotations = [];
if (userNames !== '') {
const colors = [
"#ff1111",
"#0011a3",
"#bfbf00",
"#a55111",
"#000fff",
"#999999",
"#ff00ff",
"#dd0000",
"#ff7777",
"#000000",
];
const userNameList = Array.from(new Set(userNames.split(',').map(s => s.trim())));
const resultInfo = taskInfo['ResultInfo'];
let userNameListLength = userNameList.length;
let loop = 0;
let invalidUsers = [];
for (const userName of userNameList) {
if (!(userName in resultInfo)) {
invalidUsers.push(userName);
userNameListLength--;
}
}
for (const userName of userNameList) {
if (!(userName in resultInfo)) {
continue;
}
const userLine = {
type: "line",
x0: resultInfo[userName]['Score'] / 100,
y0: 0,
x1: resultInfo[userName]['Score'] / 100,
y1: 1,
yref: "paper",
line: {
color: colors[loop],
width: 1.8
}
};
const annotation = {
showarrow: true,
text: userName + '<br>Score:' + resultInfo[userName]['Score'] / 100,
x: resultInfo[userName]['Score'] / 100,
yref: "paper",
y: loop / userNameListLength,
ax: 5,
align: "left",
xanchor: "left",
yanchor: "bottom",
font: {
color: colors[loop],
size: 12
}
};
layout["shapes"].push(userLine);
layout.annotations.push(annotation);
loop++;
}
if (invalidUsers.length !== 0) {
document.getElementById('invalid-users').textContent = 'Invalid usernames: ' + invalidUsers.join(', ');
} else {
document.getElementById('invalid-users').textContent = ''
}
}
Plotly.newPlot(this.histogramArea, data, layout, config);
}