atcoder-standings-difficulty-analyzer

順位表の得点情報を集計し,推定 difficulty やその推移を表示します.

目前為 2021-01-09 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name atcoder-standings-difficulty-analyzer
  3. // @namespace iilj
  4. // @version 2021.1.9.0
  5. // @description 順位表の得点情報を集計し,推定 difficulty やその推移を表示します.
  6. // @author iilj
  7. // @supportURL https://github.com/iilj/atcoder-standings-difficulty-analyzer/issues
  8. // @match https://atcoder.jp/*standings*
  9. // @exclude https://atcoder.jp/*standings/json
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/plotly.js/1.33.1/plotly.min.js
  11. // @resource loaders.min.css https://cdnjs.cloudflare.com/ajax/libs/loaders.css/0.1.2/loaders.min.css
  12. // @grant GM_getResourceText
  13. // @grant GM_addStyle
  14. // ==/UserScript==
  15.  
  16. /**
  17. * 問題ごとの結果エントリ
  18. * @typedef {Object} TaskResultEntry
  19. * @property {any} Additional 謎
  20. * @property {number} Count 提出回数
  21. * @property {number} Elapsed コンテスト開始からの経過時間 [ns].
  22. * @property {number} Failure 非 AC の提出数(ACするまではペナルティではない).
  23. * @property {boolean} Frozen アカウントが凍結済みかどうか?
  24. * @property {number} Penalty ペナルティ数
  25. * @property {boolean} Pending ジャッジ中かどうか?
  26. * @property {number} Score 得点(×100)
  27. * @property {number} Status 1 のとき満点? 6 のとき部分点?
  28. */
  29.  
  30. /**
  31. * 全問題の結果
  32. * @typedef {Object} TotalResultEntry
  33. * @property {number} Accepted 正解した問題数
  34. * @property {any} Additional 謎
  35. * @property {number} Count 提出回数
  36. * @property {number} Elapsed コンテスト開始からの経過時間 [ns].
  37. * @property {boolean} Frozen アカウントが凍結済みかどうか?
  38. * @property {number} Penalty ペナルティ数
  39. * @property {number} Score 得点(×100)
  40. */
  41.  
  42. /**
  43. * 順位表エントリ
  44. * @typedef {Object} StandingsEntry
  45. * @property {any} Additional 謎
  46. * @property {string} Affiliation 所属.IsTeam = true のときは,チームメンバを「, 」で結合した文字列.
  47. * @property {number} AtCoderRank AtCoder 内順位
  48. * @property {number} Competitions Rated コンテスト参加回数
  49. * @property {string} Country 国ラベル."JP" など.
  50. * @property {string} DisplayName 表示名."hitonanode" など.
  51. * @property {number} EntireRank コンテスト順位?
  52. * @property {boolean} IsRated Rated かどうか
  53. * @property {boolean} IsTeam チームかどうか
  54. * @property {number} OldRating コンテスト前のレーティング.コンテスト後のみ有効.
  55. * @property {number} Rank コンテスト順位?
  56. * @property {number} Rating コンテスト後のレーティング
  57. * @property {{[key: string]: TaskResultEntry}} TaskResults 問題ごとの結果.参加登録していない人は空.
  58. * @property {TotalResultEntry} TotalResult 全体の結果
  59. * @property {boolean} UserIsDeleted ユーザアカウントが削除済みかどうか
  60. * @property {string} UserName ユーザ名."hitonanode" など.
  61. * @property {string} UserScreenName ユーザの表示名."hitonanode" など.
  62. */
  63.  
  64. /**
  65. * 問題エントリ
  66. * @typedef {Object} TaskInfoEntry
  67. * @property {string} Assignment 問題ラベル."A" など.
  68. * @property {string} TaskName 問題名.
  69. * @property {string} TaskScreenName 問題の slug. "abc185_a" など.
  70. */
  71.  
  72. /**
  73. * 順位表情報
  74. * @typedef {Object} Standings
  75. * @property {any} AdditionalColumns 謎
  76. * @property {boolean} Fixed 謎
  77. * @property {StandingsEntry[]} StandingsData 順位表データ
  78. * @property {TaskInfoEntry[]} TaskInfo 問題データ
  79. */
  80.  
  81. /* globals vueStandings, $, contestScreenName, startTime, endTime, userScreenName, Plotly */
  82.  
  83. (() => {
  84. 'use strict';
  85.  
  86. // loader のスタイル設定
  87. const loaderStyles = GM_getResourceText("loaders.min.css");
  88. const loaderWrapperStyles = `
  89. #acssa-table {
  90. width: 100%;
  91. table-layout: fixed;
  92. margin-bottom: 1.5rem;
  93. }
  94. #acssa-thead {
  95. font-weight: bold;
  96. }
  97. .acssa-loader-wrapper {
  98. background-color: #337ab7;
  99. display: flex;
  100. justify-content: center;
  101. align-items: center;
  102. padding: 1rem;
  103. margin-bottom: 1.5rem;
  104. border-radius: 3px;
  105. }
  106. #acssa-tab-wrapper {
  107. display: none;
  108. }
  109. #acssa-chart-tab, #acssa-checkbox-tab {
  110. margin-bottom: 0.5rem;
  111. display: inline-block;
  112. }
  113. #acssa-chart-tab a, #acssa-checkbox-tab label, #acssa-checkbox-tab label input {
  114. cursor: pointer;
  115. }
  116. #acssa-chart-tab span.glyphicon {
  117. margin-right: 0.5rem;
  118. }
  119. #acssa-checkbox-tab label, #acssa-checkbox-tab input {
  120. margin: 0;
  121. }
  122. #acssa-checkbox-tab li a {
  123. color: black;
  124. }
  125. #acssa-checkbox-tab li a:hover {
  126. background-color: transparent;
  127. }
  128. .acssa-chart-wrapper {
  129. display: none;
  130. }
  131. .acssa-chart-wrapper.acssa-chart-wrapper-active {
  132. display: block;
  133. }
  134. .acssa-task-checked {
  135. color: green;
  136. margin-left: 0.5rem;
  137. }
  138. #acssa-checkbox-toggle-log-plot-parent {
  139. display: none;
  140. }
  141. `;
  142. GM_addStyle(loaderStyles + loaderWrapperStyles);
  143.  
  144. class RatingConverter {
  145. /** 表示用の低レート帯補正レート → 低レート帯補正前のレート
  146. * @type {(correctedRating: number) => number} */
  147. static toRealRating = (correctedRating) => {
  148. if (correctedRating >= 400) return correctedRating;
  149. else return 400 * (1 - Math.log(400 / correctedRating));
  150. };
  151.  
  152. /** 低レート帯補正前のレート → 内部レート推定値
  153. * @type {(correctedRating: number) => number} */
  154. static toInnerRating = (realRating, comp) => {
  155. return realRating + 1200 * (Math.sqrt(1 - Math.pow(0.81, comp)) / (1 - Math.pow(0.9, comp)) - 1) / (Math.sqrt(19) - 1);
  156. };
  157.  
  158. /** 低レート帯補正前のレート → 表示用の低レート帯補正レート
  159. * @type {(correctedRating: number) => number} */
  160. static toCorrectedRating = (realRating) => {
  161. if (realRating >= 400) return realRating;
  162. else return Math.floor(400 / Math.exp((400 - realRating) / 400));
  163. };
  164. }
  165.  
  166. class DifficultyCalculator {
  167. /** @constructor
  168. * @type {(sortedInnerRatings: number[]) => DifficultyCalculator}
  169. */
  170. constructor(sortedInnerRatings) {
  171. this.innerRatings = sortedInnerRatings;
  172. /** @type {Map<number, number>} */
  173. this.prepared = new Map();
  174. /** @type {Map<number, number>} */
  175. this.memo = new Map();
  176. }
  177.  
  178. perf2ExpectedAcceptedCount = (m) => {
  179. let expectedAcceptedCount;
  180. if (this.prepared.has(m)) {
  181. expectedAcceptedCount = this.prepared.get(m);
  182. } else {
  183. expectedAcceptedCount = this.innerRatings.reduce((prev_expected_accepts, innerRating) =>
  184. prev_expected_accepts += 1 / (1 + Math.pow(6, (m - innerRating) / 400)), 0);
  185. this.prepared.set(m, expectedAcceptedCount);
  186. }
  187. return expectedAcceptedCount;
  188. };
  189.  
  190. perf2Ranking = (x) => this.perf2ExpectedAcceptedCount(x) + 0.5;
  191.  
  192. /** Difficulty 推定値を算出する
  193. * @type {((acceptedCount: number) => number)} */
  194. binarySearch = (acceptedCount) => {
  195. if (this.memo.has(acceptedCount)) {
  196. return this.memo.get(acceptedCount);
  197. }
  198. let lb = -10000;
  199. let ub = 10000;
  200. while (ub - lb > 1) {
  201. const m = Math.floor((ub + lb) / 2);
  202. const expectedAcceptedCount = this.perf2ExpectedAcceptedCount(m);
  203.  
  204. if (expectedAcceptedCount < acceptedCount) ub = m;
  205. else lb = m;
  206. }
  207. const difficulty = lb
  208. const correctedDifficulty = RatingConverter.toCorrectedRating(difficulty);
  209. this.memo.set(acceptedCount, correctedDifficulty);
  210. return correctedDifficulty;
  211. };
  212. }
  213.  
  214. /** @type {(ar: number[], n: number) => number} */
  215. const arrayLowerBound = (arr, n) => {
  216. let first = 0, last = arr.length - 1, middle;
  217. while (first <= last) {
  218. middle = 0 | (first + last) / 2;
  219. if (arr[middle] < n) first = middle + 1;
  220. else last = middle - 1;
  221. }
  222. return first;
  223. };
  224.  
  225. /** @type {(rating: number) => string} */
  226. const getColor = (rating) => {
  227. if (rating < 400) return '#808080'; // gray
  228. else if (rating < 800) return '#804000'; // brown
  229. else if (rating < 1200) return '#008000'; // green
  230. else if (rating < 1600) return '#00C0C0'; // cyan
  231. else if (rating < 2000) return '#0000FF'; // blue
  232. else if (rating < 2400) return '#C0C000'; // yellow
  233. else if (rating < 2800) return '#FF8000'; // orange
  234. else if (rating == 9999) return '#000000';
  235. return '#FF0000'; // red
  236. };
  237.  
  238. /** レートを表す難易度円(◒)の HTML 文字列を生成
  239. * @type {(rating: number, isSmall?: boolean) => string} */
  240. const generateDifficultyCircle = (rating, isSmall = true) => {
  241. const size = (isSmall ? 12 : 36);
  242. const borderWidth = (isSmall ? 1 : 3);
  243.  
  244. const style = `display:inline-block;border-radius:50%;border-style:solid;border-width:${borderWidth}px;`
  245. + `margin-right:5px;vertical-align:initial;height:${size}px;width:${size}px;`;
  246.  
  247. if (rating < 3200) {
  248. // 色と円がどのぐらい満ちているかを計算
  249. const color = getColor(rating);
  250. const percentFull = (rating % 400) / 400 * 100;
  251.  
  252. // ◒を生成
  253. return `
  254. <span style='${style}border-color:${color};background:`
  255. + `linear-gradient(to top, ${color} 0%, ${color} ${percentFull}%, `
  256. + `rgba(0, 0, 0, 0) ${percentFull}%, rgba(0, 0, 0, 0) 100%); '>
  257. </span>`;
  258.  
  259. }
  260. // 金銀銅は例外処理
  261. else if (rating < 3600) {
  262. return `<span style="${style}border-color: rgb(150, 92, 44);`
  263. + 'background: linear-gradient(to right, rgb(150, 92, 44), rgb(255, 218, 189), rgb(150, 92, 44));"></span>';
  264. } else if (rating < 4000) {
  265. return `<span style="${style}border-color: rgb(128, 128, 128);`
  266. + 'background: linear-gradient(to right, rgb(128, 128, 128), white, rgb(128, 128, 128));"></span>';
  267. } else {
  268. return `<span style="${style}border-color: rgb(255, 215, 0);`
  269. + 'background: linear-gradient(to right, rgb(255, 215, 0), white, rgb(255, 215, 0));"></span>';
  270. }
  271. }
  272.  
  273. /** @type {(sec: number) => string} */
  274. const formatTimespan = (sec) => {
  275. let sign;
  276. if (sec >= 0) {
  277. sign = "";
  278. } else {
  279. sign = "-";
  280. sec *= -1;
  281. }
  282. return `${sign}${Math.floor(sec / 60)}:${`0${sec % 60}`.slice(-2)}`;
  283. };
  284.  
  285. /** 現在のページから,コンテストの開始から終了までの秒数を抽出する
  286. * @type {() => number}
  287. */
  288. const getContestDurationSec = () => {
  289. if (contestScreenName.startsWith("past")) {
  290. return 300 * 60;
  291. }
  292. return (endTime - startTime) / 1000;
  293. };
  294.  
  295. /** @type {(contestScreenName: string) => number} */
  296. const getCenterOfInnerRating = (contestScreenName) => {
  297. if (contestScreenName.startsWith("agc")) {
  298. const contestNumber = Number(contestScreenName.substring(3, 6));
  299. return (contestNumber >= 34) ? 1200 : 1600;
  300. }
  301. if (contestScreenName.startsWith("arc")) {
  302. const contestNumber = Number(contestScreenName.substring(3, 6));
  303. return (contestNumber >= 104) ? 1000 : 1600;
  304. }
  305. return 800;
  306. };
  307. const centerOfInnerRating = getCenterOfInnerRating(contestScreenName);
  308.  
  309. let working = false;
  310. let oldStandingsData = null;
  311.  
  312. /** 順位表更新時の処理:テーブル追加
  313. * @type {(v: Standings) => void} */
  314. const onStandingsChanged = async (standings) => {
  315. if (!standings) return;
  316. if (working) return;
  317.  
  318. const tasks = standings.TaskInfo;
  319. const standingsData = standings.StandingsData; // vueStandings.filteredStandings;
  320.  
  321. if (oldStandingsData === standingsData) return;
  322. oldStandingsData = standingsData;
  323. working = true;
  324. // console.log(standings);
  325.  
  326. { // remove old contents
  327. const oldContents = document.getElementById("acssa-contents");
  328. if (oldContents) {
  329. // oldContents.parentNode.removeChild(oldContents);
  330. oldContents.remove();
  331. }
  332. }
  333.  
  334. /** 問題ごとの最終 AC 時刻リスト.
  335. * @type {Map<number, number[]>} */
  336. const scoreLastAcceptedTimeMap = new Map();
  337.  
  338. // コンテスト中かどうか判別する
  339. let isDuringContest = true;
  340. for (let i = 0; i < standingsData.length; ++i) {
  341. const standingsEntry = standingsData[i];
  342. if (standingsEntry.OldRating > 0) {
  343. isDuringContest = false;
  344. break;
  345. }
  346. }
  347.  
  348. /** 各問題の正答者数.
  349. * @type {number[]} */
  350. const taskAcceptedCounts = Array(tasks.length);
  351. taskAcceptedCounts.fill(0);
  352.  
  353. /** 各問題の正答時間リスト.秒単位で格納する.
  354. * @type {number[][]} */
  355. const taskAcceptedElapsedTimes = [...Array(tasks.length)].map((_, i) => []);
  356. // taskAcceptedElapsedTimes.fill([]); // これだと同じインスタンスで埋めてしまう
  357.  
  358. /** 内部レートのリスト.
  359. * @type {number[]} */
  360. const innerRatings = [];
  361.  
  362. const NS2SEC = 1000000000;
  363.  
  364. /** @type {{[key: string]: number}} */
  365. const innerRatingsFromPredictor = await (async () => {
  366. try {
  367. const res = await fetch(`https://data.ac-predictor.com/aperfs/${contestScreenName}.json`);
  368. if (res.ok) return await res.json();
  369. } catch (e) {
  370. console.warn(e);
  371. }
  372. return {};
  373. })();
  374.  
  375. /** 現在のユーザの各問題の AC 時刻.
  376. * @type {number[]} */
  377. const yourTaskAcceptedElapsedTimes = Array(tasks.length);
  378. yourTaskAcceptedElapsedTimes.fill(-1);
  379. /** 現在のユーザのスコア */
  380. let yourScore = -1;
  381. /** 現在のユーザの最終 AC 時刻 */
  382. let yourLastAcceptedTime = -1;
  383.  
  384. // 順位表情報を走査する(内部レートのリストと正答時間リストを構築する)
  385. let participants = 0;
  386. for (let i = 0; i < standingsData.length; ++i) {
  387. const standingsEntry = standingsData[i];
  388.  
  389. if (!standingsEntry.TaskResults) continue; // 参加登録していない
  390. if (standingsEntry.UserIsDeleted) continue; // アカウント削除
  391. let correctedRating = isDuringContest ? standingsEntry.Rating : standingsEntry.OldRating;
  392. const isTeamOrBeginner = (correctedRating === 0);
  393. if (isTeamOrBeginner) {
  394. // continue; // 初参加 or チーム
  395. correctedRating = centerOfInnerRating;
  396. }
  397.  
  398. // これは飛ばしちゃダメ(提出しても 0 AC だと Penalty == 0 なので)
  399. // if (standingsEntry.TotalResult.Score == 0 && standingsEntry.TotalResult.Penalty == 0) continue;
  400.  
  401. let score = 0;
  402. let penalty = 0;
  403. for (let j = 0; j < tasks.length; ++j) {
  404. const taskResultEntry = standingsEntry.TaskResults[tasks[j].TaskScreenName];
  405. if (!taskResultEntry) continue; // 未提出
  406. score += taskResultEntry.Score;
  407. penalty += (taskResultEntry.Score === 0 ? taskResultEntry.Failure : taskResultEntry.Penalty);
  408. }
  409. if (score === 0 && penalty === 0) continue; // NoSub を飛ばす
  410. participants++;
  411. // console.log(i + 1, score, penalty);
  412.  
  413. score /= 100;
  414. if (scoreLastAcceptedTimeMap.has(score)) {
  415. scoreLastAcceptedTimeMap.get(score).push(standingsEntry.TotalResult.Elapsed / NS2SEC)
  416. } else {
  417. scoreLastAcceptedTimeMap.set(score, [standingsEntry.TotalResult.Elapsed / NS2SEC]);
  418. }
  419.  
  420. const innerRating = isTeamOrBeginner
  421. ? correctedRating
  422. : (standingsEntry.UserScreenName in innerRatingsFromPredictor)
  423. ? innerRatingsFromPredictor[standingsEntry.UserScreenName]
  424. : RatingConverter.toInnerRating(
  425. Math.max(RatingConverter.toRealRating(correctedRating), 1), standingsEntry.Competitions);
  426. if (innerRating) innerRatings.push(innerRating);
  427. else {
  428. console.log(i, innerRating, correctedRating, standingsEntry.Competitions);
  429. continue;
  430. }
  431. for (let j = 0; j < tasks.length; ++j) {
  432. const taskResultEntry = standingsEntry.TaskResults[tasks[j].TaskScreenName];
  433. const isAccepted = (taskResultEntry?.Score > 0 && taskResultEntry?.Status == 1);
  434. if (isAccepted) {
  435. ++taskAcceptedCounts[j];
  436. taskAcceptedElapsedTimes[j].push(taskResultEntry.Elapsed / NS2SEC);
  437. }
  438. }
  439. if (standingsEntry.UserScreenName == userScreenName) {
  440. yourScore = score;
  441. yourLastAcceptedTime = standingsEntry.TotalResult.Elapsed / NS2SEC;
  442. for (let j = 0; j < tasks.length; ++j) {
  443. const taskResultEntry = standingsEntry.TaskResults[tasks[j].TaskScreenName];
  444. const isAccepted = (taskResultEntry?.Score > 0 && taskResultEntry?.Status == 1);
  445. if (isAccepted) {
  446. yourTaskAcceptedElapsedTimes[j] = taskResultEntry.Elapsed / NS2SEC;
  447. }
  448. }
  449. }
  450. }
  451. innerRatings.sort((a, b) => a - b);
  452.  
  453. const dc = new DifficultyCalculator(innerRatings);
  454.  
  455. const plotlyDifficultyChartId = 'acssa-mydiv-difficulty';
  456. const plotlyAcceptedCountChartId = 'acssa-mydiv-accepted-count';
  457. const plotlyLastAcceptedTimeChartId = 'acssa-mydiv-accepted-time';
  458. $('#vue-standings').prepend(`
  459. <div id="acssa-contents">
  460. <table id="acssa-table" class="table table-bordered table-hover th-center td-center td-middle">
  461. <tbody>
  462. <tr id="acssa-thead"></tr>
  463. </tbody>
  464. <tbody>
  465. <tr id="acssa-tbody"></tr>
  466. </tbody>
  467. </table>
  468. <div id="acssa-tab-wrapper">
  469. <ul class="nav nav-pills small" id="acssa-chart-tab">
  470. <li class="active">
  471. <a class="acssa-chart-tab-button"><span class="glyphicon glyphicon-stats" aria-hidden="true"></span>Difficulty</a></li>
  472. <li>
  473. <a class="acssa-chart-tab-button"><span class="glyphicon glyphicon-stats" aria-hidden="true"></span>AC Count</a></li>
  474. <li>
  475. <a class="acssa-chart-tab-button"><span class="glyphicon glyphicon-stats" aria-hidden="true"></span>LastAcceptedTime</a></li>
  476. </ul>
  477. <ul class="nav nav-pills" id="acssa-checkbox-tab">
  478. <li>
  479. <a><label><input type="checkbox" id="acssa-checkbox-toggle-your-result-visibility" checked> Plot your result</label></a></li>
  480. <li id="acssa-checkbox-toggle-log-plot-parent">
  481. <a><label><input type="checkbox" id="acssa-checkbox-toggle-log-plot">Log plot</label></a></li>
  482. </ul>
  483. </div>
  484. <div id="acssa-loader" class="loader acssa-loader-wrapper">
  485. <div class="loader-inner ball-pulse">
  486. <div></div>
  487. <div></div>
  488. <div></div>
  489. </div>
  490. </div>
  491. <div id="acssa-chart-block">
  492. <div class="acssa-chart-wrapper acssa-chart-wrapper-active" id="${plotlyDifficultyChartId}-wrapper">
  493. <div id="${plotlyDifficultyChartId}" style="width:100%;"></div>
  494. </div>
  495. <div class="acssa-chart-wrapper" id="${plotlyAcceptedCountChartId}-wrapper">
  496. <div id="${plotlyAcceptedCountChartId}" style="width:100%;"></div>
  497. </div>
  498. <div class="acssa-chart-wrapper" id="${plotlyLastAcceptedTimeChartId}-wrapper">
  499. <div id="${plotlyLastAcceptedTimeChartId}" style="width:100%;"></div>
  500. </div>
  501. </div>
  502. </div>
  503. `);
  504.  
  505. // チェックボックス操作時のイベントを登録する
  506. /** @type {HTMLInputElement} */
  507. const checkbox = document.getElementById("acssa-checkbox-toggle-your-result-visibility");
  508. checkbox.addEventListener("change", () => {
  509. if (checkbox.checked) {
  510. document.querySelectorAll('.acssa-task-checked').forEach(elm => {
  511. elm.style.display = 'inline';
  512. });
  513. } else {
  514. document.querySelectorAll('.acssa-task-checked').forEach(elm => {
  515. elm.style.display = 'none';
  516. });
  517. }
  518. });
  519.  
  520. let activeTab = 0;
  521. const showYourResult = [true, true, true];
  522.  
  523. let yourDifficultyChartData = null;
  524. let yourAcceptedCountChartData = null;
  525. let yourLastAcceptedTimeChartData = null;
  526. let yourLastAcceptedTimeChartDataIndex = -1;
  527. const onCheckboxChanged = () => {
  528. showYourResult[activeTab] = checkbox.checked;
  529. if (checkbox.checked) {
  530. // show
  531. switch (activeTab) {
  532. case 0:
  533. if (yourScore > 0) Plotly.addTraces(plotlyDifficultyChartId, yourDifficultyChartData);
  534. break;
  535. case 1:
  536. if (yourScore > 0) Plotly.addTraces(plotlyAcceptedCountChartId, yourAcceptedCountChartData);
  537. break;
  538. case 2:
  539. if (yourLastAcceptedTimeChartDataIndex != -1) {
  540. Plotly.addTraces(plotlyLastAcceptedTimeChartId, yourLastAcceptedTimeChartData, yourLastAcceptedTimeChartDataIndex);
  541. }
  542. break;
  543. default:
  544. break;
  545. }
  546. } else {
  547. // hide
  548. switch (activeTab) {
  549. case 0:
  550. if (yourScore > 0) Plotly.deleteTraces(plotlyDifficultyChartId, -1);
  551. break;
  552. case 1:
  553. if (yourScore > 0) Plotly.deleteTraces(plotlyAcceptedCountChartId, -1);
  554. break;
  555. case 2:
  556. if (yourLastAcceptedTimeChartDataIndex != -1) {
  557. Plotly.deleteTraces(plotlyLastAcceptedTimeChartId, yourLastAcceptedTimeChartDataIndex);
  558. }
  559. break;
  560. default:
  561. break;
  562. }
  563. }
  564. };
  565.  
  566. /** @type {HTMLInputElement} */
  567. const logPlotCheckbox = document.getElementById('acssa-checkbox-toggle-log-plot');
  568. const logPlotCheckboxParent = document.getElementById('acssa-checkbox-toggle-log-plot-parent');
  569.  
  570. let acceptedCountYMax = -1;
  571. const useLogPlot = [false, false, false];
  572. const onLogPlotCheckboxChanged = () => {
  573. if (acceptedCountYMax == -1) return;
  574. useLogPlot[activeTab] = logPlotCheckbox.checked;
  575. if (activeTab == 1) {
  576. if (logPlotCheckbox.checked) {
  577. // log plot
  578. const layout = {
  579. yaxis: {
  580. type: 'log',
  581. range: [
  582. Math.log10(0.5),
  583. Math.log10(acceptedCountYMax)
  584. ],
  585. },
  586. };
  587. Plotly.relayout(plotlyAcceptedCountChartId, layout);
  588. } else {
  589. // linear plot
  590. const layout = {
  591. yaxis: {
  592. type: 'linear',
  593. range: [
  594. 0,
  595. acceptedCountYMax
  596. ],
  597. },
  598. };
  599. Plotly.relayout(plotlyAcceptedCountChartId, layout);
  600. }
  601. } else if (activeTab == 2) {
  602. if (logPlotCheckbox.checked) {
  603. // log plot
  604. const layout = {
  605. xaxis: {
  606. type: 'log',
  607. range: [
  608. Math.log10(0.5),
  609. Math.log10(participants)
  610. ],
  611. },
  612. };
  613. Plotly.relayout(plotlyLastAcceptedTimeChartId, layout);
  614. } else {
  615. // linear plot
  616. const layout = {
  617. xaxis: {
  618. type: 'linear',
  619. range: [
  620. 0,
  621. participants
  622. ],
  623. },
  624. };
  625. Plotly.relayout(plotlyLastAcceptedTimeChartId, layout);
  626. }
  627. }
  628. };
  629.  
  630. document.querySelectorAll(".acssa-chart-tab-button").forEach((btn, key) => {
  631. btn.addEventListener("click", () => {
  632. // check whether active or not
  633. if (btn.parentElement.className == "active") return;
  634. // modify visibility
  635. activeTab = key;
  636. document.querySelector("#acssa-chart-tab li.active").classList.remove("active");
  637. document.querySelector(`#acssa-chart-tab li:nth-child(${key + 1})`).classList.add("active");
  638. document.querySelector("#acssa-chart-block div.acssa-chart-wrapper-active").classList.remove("acssa-chart-wrapper-active");
  639. document.querySelector(`#acssa-chart-block div.acssa-chart-wrapper:nth-child(${key + 1})`).classList.add("acssa-chart-wrapper-active");
  640. // resize charts
  641. switch (key) {
  642. case 0:
  643. Plotly.relayout(plotlyDifficultyChartId, { width: document.getElementById(plotlyDifficultyChartId).clientWidth });
  644. logPlotCheckboxParent.style.display = 'none';
  645. break;
  646. case 1:
  647. Plotly.relayout(plotlyAcceptedCountChartId, { width: document.getElementById(plotlyAcceptedCountChartId).clientWidth });
  648. logPlotCheckboxParent.style.display = 'block';
  649. break;
  650. case 2:
  651. Plotly.relayout(plotlyLastAcceptedTimeChartId, { width: document.getElementById(plotlyLastAcceptedTimeChartId).clientWidth });
  652. logPlotCheckboxParent.style.display = 'block';
  653. break;
  654. default:
  655. break;
  656. }
  657. if (showYourResult[activeTab] !== checkbox.checked) {
  658. onCheckboxChanged();
  659. }
  660. if (activeTab !== 0 && useLogPlot[activeTab] !== logPlotCheckbox.checked) {
  661. onLogPlotCheckboxChanged();
  662. }
  663. });
  664. });
  665.  
  666. logPlotCheckbox.addEventListener('change', onLogPlotCheckboxChanged);
  667.  
  668. // 現在の Difficulty テーブルを構築する
  669. for (let j = 0; j < tasks.length; ++j) {
  670. const correctedDifficulty = RatingConverter.toCorrectedRating(dc.binarySearch(taskAcceptedCounts[j]));
  671. document.getElementById("acssa-thead").insertAdjacentHTML("beforeend", `
  672. <td>
  673. ${tasks[j].Assignment}
  674. ${yourTaskAcceptedElapsedTimes[j] === -1 ? '' : '<span class="acssa-task-checked">✓</span>'}
  675. </td>
  676. `);
  677. const id = `td-assa-difficulty-${j}`;
  678. document.getElementById("acssa-tbody").insertAdjacentHTML("beforeend", `
  679. <td id="${id}" style="color:${getColor(correctedDifficulty)};">
  680. ${correctedDifficulty === 9999 ? '-' : correctedDifficulty}</td>
  681. `);
  682. if (correctedDifficulty !== 9999) {
  683. document.getElementById(id).insertAdjacentHTML(
  684. "afterbegin", generateDifficultyCircle(correctedDifficulty));
  685. }
  686. }
  687.  
  688. if (yourScore == -1) {
  689. // disable checkbox
  690. checkbox.checked = false;
  691. checkbox.disabled = true;
  692. checkbox.parentElement.style.cursor = 'default';
  693. checkbox.parentElement.style.textDecoration = 'line-through';
  694. }
  695.  
  696. // 順位表のその他の描画を優先するために,後回しにする
  697. setTimeout(() => {
  698. const maxAcceptedCount = taskAcceptedCounts.reduce((a, b) => Math.max(a, b));
  699. const yMax = RatingConverter.toCorrectedRating(dc.binarySearch(1));
  700. const yMin = RatingConverter.toCorrectedRating(dc.binarySearch(Math.max(2, maxAcceptedCount)));
  701.  
  702. // 以降の計算は時間がかかる
  703.  
  704. taskAcceptedElapsedTimes.forEach(ar => {
  705. ar.sort((a, b) => a - b);
  706. });
  707.  
  708. // 時系列データの準備
  709. /** Difficulty Chart のデータ
  710. * @type {{x: number, y: number, type: string, name: string}[]} */
  711. const difficultyChartData = [];
  712. /** AC Count Chart のデータ
  713. * @type {{x: number, y: number, type: string, name: string}[]} */
  714. const acceptedCountChartData = [];
  715.  
  716. for (let j = 0; j < tasks.length; ++j) { //
  717. const interval = Math.ceil(taskAcceptedCounts[j] / 140);
  718. /** @type {[number[], number[]]} */
  719. const [taskAcceptedElapsedTimesForChart, taskAcceptedCountsForChart] = taskAcceptedElapsedTimes[j].reduce(
  720. ([ar, arr], tm, idx) => {
  721. const tmpInterval = Math.max(1, Math.min(Math.ceil(idx / 10), interval));
  722. if (idx % tmpInterval == 0 || idx == taskAcceptedCounts[j] - 1) {
  723. ar.push(tm);
  724. arr.push(idx + 1);
  725. }
  726. return [ar, arr];
  727. },
  728. [[], []]
  729. );
  730.  
  731. difficultyChartData.push({
  732. x: taskAcceptedElapsedTimesForChart,
  733. y: taskAcceptedCountsForChart.map(taskAcceptedCountForChart => dc.binarySearch(taskAcceptedCountForChart)),
  734. type: 'scatter',
  735. name: `${tasks[j].Assignment}`,
  736. });
  737. acceptedCountChartData.push({
  738. x: taskAcceptedElapsedTimesForChart,
  739. y: taskAcceptedCountsForChart,
  740. type: 'scatter',
  741. name: `${tasks[j].Assignment}`,
  742. });
  743. }
  744.  
  745. // 現在のユーザのデータを追加
  746. const yourMarker = {
  747. size: 10,
  748. symbol: "cross",
  749. color: 'red',
  750. line: {
  751. color: 'white',
  752. width: 1,
  753. },
  754. };
  755. if (yourScore !== -1) {
  756. /** @type {number[]} */
  757. const yourAcceptedTimes = [];
  758. /** @type {number[]} */
  759. const yourAcceptedDifficulties = [];
  760. /** @type {number[]} */
  761. const yourAcceptedCounts = [];
  762.  
  763. for (let j = 0; j < tasks.length; ++j) {
  764. if (yourTaskAcceptedElapsedTimes[j] !== -1) {
  765. yourAcceptedTimes.push(yourTaskAcceptedElapsedTimes[j]);
  766. const yourAcceptedCount = arrayLowerBound(taskAcceptedElapsedTimes[j], yourTaskAcceptedElapsedTimes[j]) + 1;
  767. yourAcceptedCounts.push(yourAcceptedCount);
  768. yourAcceptedDifficulties.push(dc.binarySearch(yourAcceptedCount));
  769. }
  770. }
  771.  
  772. yourDifficultyChartData = {
  773. x: yourAcceptedTimes,
  774. y: yourAcceptedDifficulties,
  775. mode: 'markers',
  776. type: 'scatter',
  777. name: `${userScreenName}`,
  778. marker: yourMarker,
  779. };
  780. yourAcceptedCountChartData = {
  781. x: yourAcceptedTimes,
  782. y: yourAcceptedCounts,
  783. mode: 'markers',
  784. type: 'scatter',
  785. name: `${userScreenName}`,
  786. marker: yourMarker,
  787. };
  788. difficultyChartData.push(yourDifficultyChartData);
  789. acceptedCountChartData.push(yourAcceptedCountChartData);
  790. }
  791.  
  792. // 得点と提出時間データの準備
  793. /** @type {{x: number, y: number, type: string, name: string}[]} */
  794. const lastAcceptedTimeChartData = [];
  795. const scores = [...scoreLastAcceptedTimeMap.keys()];
  796. scores.sort((a, b) => b - a);
  797. let acc = 0;
  798. let maxAcceptedTime = 0;
  799. scores.forEach(score => {
  800. const lastAcceptedTimes = scoreLastAcceptedTimeMap.get(score);
  801. lastAcceptedTimes.sort((a, b) => a - b);
  802. const interval = Math.ceil(lastAcceptedTimes.length / 100);
  803. /** @type {number[]} */
  804. const lastAcceptedTimesForChart = lastAcceptedTimes.reduce((ar, tm, idx) => {
  805. if (idx % interval == 0 || idx == lastAcceptedTimes.length - 1) ar.push(tm);
  806. return ar;
  807. }, []);
  808. const lastAcceptedTimesRanks = lastAcceptedTimes.reduce((ar, tm, idx) => {
  809. if (idx % interval == 0 || idx == lastAcceptedTimes.length - 1) ar.push(acc + idx + 1);
  810. return ar;
  811. }, []);
  812.  
  813. lastAcceptedTimeChartData.push({
  814. x: lastAcceptedTimesRanks,
  815. y: lastAcceptedTimesForChart,
  816. type: 'scatter',
  817. name: `${score}`,
  818. });
  819.  
  820. if (score === yourScore) {
  821. const lastAcceptedTimesRank = arrayLowerBound(lastAcceptedTimes, yourLastAcceptedTime);
  822. yourLastAcceptedTimeChartData = {
  823. x: [acc + lastAcceptedTimesRank + 1],
  824. y: [yourLastAcceptedTime],
  825. mode: 'markers',
  826. type: 'scatter',
  827. name: `${userScreenName}`,
  828. marker: yourMarker,
  829. };
  830. yourLastAcceptedTimeChartDataIndex = lastAcceptedTimeChartData.length + 0;
  831. lastAcceptedTimeChartData.push(yourLastAcceptedTimeChartData);
  832. }
  833.  
  834. acc += lastAcceptedTimes.length;
  835. if (lastAcceptedTimes[lastAcceptedTimes.length - 1] > maxAcceptedTime) {
  836. maxAcceptedTime = lastAcceptedTimes[lastAcceptedTimes.length - 1];
  837. }
  838. });
  839.  
  840. const duration = getContestDurationSec();
  841. const xtick = (60 * 10) * Math.max(1, Math.ceil(duration / (60 * 10 * 20))); // 10 分を最小単位にする
  842.  
  843. // 軸フォーマットをカスタムする
  844. // Support specifying a function for tickformat · Issue #1464 · plotly/plotly.js
  845. // https://github.com/plotly/plotly.js/issues/1464#issuecomment-498050894
  846. {
  847. const org_locale = Plotly.d3.locale;
  848. Plotly.d3.locale = (locale) => {
  849. const result = org_locale(locale);
  850. const org_number_format = result.numberFormat;
  851. result.numberFormat = (format) => {
  852. if (format != 'TIME') {
  853. return org_number_format(format)
  854. }
  855. return (x) => formatTimespan(x).toString();
  856. }
  857. return result;
  858. };
  859. }
  860.  
  861. // 背景用設定
  862. const alpha = 0.3;
  863. /** @type {[number, number, string][]} */
  864. const colors = [
  865. [0, 400, `rgba(128,128,128,${alpha})`],
  866. [400, 800, `rgba(128,0,0,${alpha})`],
  867. [800, 1200, `rgba(0,128,0,${alpha})`],
  868. [1200, 1600, `rgba(0,255,255,${alpha})`],
  869. [1600, 2000, `rgba(0,0,255,${alpha})`],
  870. [2000, 2400, `rgba(255,255,0,${alpha})`],
  871. [2400, 2800, `rgba(255,165,0,${alpha})`],
  872. [2800, 10000, `rgba(255,0,0,${alpha})`],
  873. ];
  874.  
  875. // Difficulty Chart 描画
  876. {
  877. // 描画
  878. const layout = {
  879. title: 'Difficulty',
  880. xaxis: {
  881. dtick: xtick,
  882. tickformat: 'TIME',
  883. range: [0, duration],
  884. // title: { text: 'Elapsed' }
  885. },
  886. yaxis: {
  887. dtick: 400,
  888. tickformat: 'd',
  889. range: [
  890. Math.max(0, Math.floor((yMin - 100) / 400) * 400),
  891. Math.max(0, Math.ceil((yMax + 100) / 400) * 400)
  892. ],
  893. // title: { text: 'Difficulty' }
  894. },
  895. shapes: colors.map(c => {
  896. return {
  897. type: 'rect',
  898. layer: 'below',
  899. xref: 'x',
  900. yref: 'y',
  901. x0: 0,
  902. x1: duration,
  903. y0: c[0],
  904. y1: c[1],
  905. line: { width: 0 },
  906. fillcolor: c[2]
  907. };
  908. }),
  909. margin: {
  910. b: 60,
  911. t: 30,
  912. }
  913. };
  914. const config = { autosize: true };
  915. Plotly.newPlot(plotlyDifficultyChartId, difficultyChartData, layout, config);
  916.  
  917. window.addEventListener('resize', () => {
  918. if (activeTab == 0)
  919. Plotly.relayout(plotlyDifficultyChartId, { width: document.getElementById(plotlyDifficultyChartId).clientWidth });
  920. });
  921. }
  922.  
  923. // Accepted Count Chart 描画
  924. {
  925. acceptedCountYMax = participants;
  926. /** @type {[number, number, string][]} */
  927. const rectSpans = colors.reduce((ar, cur) => {
  928. const bottom = dc.perf2ExpectedAcceptedCount(cur[1]);
  929. if (bottom > acceptedCountYMax) return ar;
  930. const top = (cur[0] == 0) ? acceptedCountYMax : dc.perf2ExpectedAcceptedCount(cur[0]);
  931. if (top < 0.5) return ar;
  932. ar.push([Math.max(0.5, bottom), Math.min(acceptedCountYMax, top), cur[2]]);
  933. return ar;
  934. }, []);
  935. // 描画
  936. const layout = {
  937. title: 'Accepted Count',
  938. xaxis: {
  939. dtick: xtick,
  940. tickformat: 'TIME',
  941. range: [0, duration],
  942. // title: { text: 'Elapsed' }
  943. },
  944. yaxis: {
  945. // type: 'log',
  946. // dtick: 100,
  947. tickformat: 'd',
  948. range: [
  949. 0,
  950. acceptedCountYMax
  951. ],
  952. // range: [
  953. // Math.log10(0.5),
  954. // Math.log10(acceptedCountYMax)
  955. // ],
  956. // title: { text: 'Difficulty' }
  957. },
  958. shapes: rectSpans.map(span => {
  959. return {
  960. type: 'rect',
  961. layer: 'below',
  962. xref: 'x',
  963. yref: 'y',
  964. x0: 0,
  965. x1: duration,
  966. y0: span[0],
  967. y1: span[1],
  968. line: { width: 0 },
  969. fillcolor: span[2]
  970. };
  971. }),
  972. margin: {
  973. b: 60,
  974. t: 30,
  975. }
  976. };
  977. const config = { autosize: true };
  978. Plotly.newPlot(plotlyAcceptedCountChartId, acceptedCountChartData, layout, config);
  979.  
  980. window.addEventListener('resize', () => {
  981. if (activeTab == 1)
  982. Plotly.relayout(plotlyAcceptedCountChartId, { width: document.getElementById(plotlyAcceptedCountChartId).clientWidth });
  983. });
  984. }
  985.  
  986. // LastAcceptedTime Chart 描画
  987. {
  988. const xMax = participants;
  989. const yMax = Math.ceil((maxAcceptedTime + xtick / 2) / xtick) * xtick;
  990. /** @type {[number, number, string][]} */
  991. const rectSpans = colors.reduce((ar, cur) => {
  992. const right = (cur[0] == 0) ? xMax : dc.perf2Ranking(cur[0]);
  993. if (right < 1) return ar;
  994. const left = dc.perf2Ranking(cur[1]);
  995. if (left > xMax) return ar;
  996. ar.push([Math.max(0, left), Math.min(xMax, right), cur[2]]);
  997. return ar;
  998. }, []);
  999. // console.log(colors);
  1000. // console.log(rectSpans);
  1001. const layout = {
  1002. title: 'LastAcceptedTime v.s. Rank',
  1003. xaxis: {
  1004. // dtick: 100,
  1005. tickformat: 'd',
  1006. range: [0, xMax],
  1007. // title: { text: 'Elapsed' }
  1008. },
  1009. yaxis: {
  1010. dtick: xtick,
  1011. tickformat: 'TIME',
  1012. range: [0, yMax],
  1013. // range: [
  1014. // Math.max(0, Math.floor((yMin - 100) / 400) * 400),
  1015. // Math.max(0, Math.ceil((yMax + 100) / 400) * 400)
  1016. // ],
  1017. // title: { text: 'Difficulty' }
  1018. },
  1019. shapes: rectSpans.map(span => {
  1020. return {
  1021. type: 'rect',
  1022. layer: 'below',
  1023. xref: 'x',
  1024. yref: 'y',
  1025. x0: span[0],
  1026. x1: span[1],
  1027. y0: 0,
  1028. y1: yMax,
  1029. line: { width: 0 },
  1030. fillcolor: span[2]
  1031. };
  1032. }),
  1033. margin: {
  1034. b: 60,
  1035. t: 30,
  1036. }
  1037. };
  1038. const config = { autosize: true };
  1039. Plotly.newPlot(plotlyLastAcceptedTimeChartId, lastAcceptedTimeChartData, layout, config);
  1040.  
  1041. window.addEventListener('resize', () => {
  1042. if (activeTab == 2)
  1043. Plotly.relayout(plotlyLastAcceptedTimeChartId, { width: document.getElementById(plotlyLastAcceptedTimeChartId).clientWidth });
  1044. });
  1045. }
  1046.  
  1047. // 現在のユーザの結果表示・非表示 toggle
  1048. checkbox.addEventListener('change', onCheckboxChanged);
  1049.  
  1050. document.getElementById('acssa-loader').style.display = 'none';
  1051. document.getElementById('acssa-tab-wrapper').style.display = 'block';
  1052. working = false;
  1053. }, 100); // end setTimeout()
  1054. };
  1055.  
  1056. // MAIN
  1057. vueStandings.$watch('standings', onStandingsChanged, { deep: true, immediate: true });
  1058.  
  1059. })();