您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
順位表の得点情報を集計し,推定 difficulty やその推移を表示します.
当前为
// ==UserScript== // @name atcoder-standings-difficulty-analyzer // @namespace iilj // @version 2021.8.1 // @description 順位表の得点情報を集計し,推定 difficulty やその推移を表示します. // @author iilj // @license MIT // @supportURL https://github.com/iilj/atcoder-standings-difficulty-analyzer/issues // @match https://atcoder.jp/*standings* // @exclude https://atcoder.jp/*standings/json // @require https://cdnjs.cloudflare.com/ajax/libs/plotly.js/1.33.1/plotly.min.js // @resource loaders.min.css https://cdnjs.cloudflare.com/ajax/libs/loaders.css/0.1.2/loaders.min.css // @grant GM_getResourceText // @grant GM_addStyle // ==/UserScript== var css = "#acssa-contents .table.acssa-table {\n width: 100%;\n table-layout: fixed;\n margin-bottom: 1.5rem;\n}\n#acssa-contents .table.acssa-table .acssa-thead {\n font-weight: bold;\n}\n#acssa-contents .table.acssa-table > tbody > tr > td.success.acssa-task-success.acssa-task-success-suppress {\n background-color: transparent;\n}\n#acssa-contents #acssa-tab-wrapper {\n display: none;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-chart-tab {\n margin-bottom: 0.5rem;\n display: inline-block;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-chart-tab a {\n cursor: pointer;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-chart-tab a span.glyphicon {\n margin-right: 0.5rem;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-checkbox-tab {\n margin-bottom: 0.5rem;\n display: inline-block;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-checkbox-tab li a {\n color: black;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-checkbox-tab li a:hover {\n background-color: transparent;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-checkbox-tab li a label {\n cursor: pointer;\n margin: 0;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-checkbox-tab li a label input {\n cursor: pointer;\n margin: 0;\n}\n#acssa-contents #acssa-tab-wrapper #acssa-checkbox-tab #acssa-checkbox-toggle-log-plot-parent {\n display: none;\n}\n#acssa-contents .acssa-loader-wrapper {\n background-color: #337ab7;\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 1rem;\n margin-bottom: 1.5rem;\n border-radius: 3px;\n}\n#acssa-contents .acssa-chart-wrapper {\n display: none;\n}\n#acssa-contents .acssa-chart-wrapper.acssa-chart-wrapper-active {\n display: block;\n}"; var teamalert = "<div class=\"alert alert-warning\">\n チーム戦順位表が提供されています.個人単位の順位表ページでは,difficulty 推定値が不正確になります.\n</div>"; const arrayLowerBound = (arr, n) => { let first = 0, last = arr.length - 1, middle; while (first <= last) { middle = 0 | ((first + last) / 2); if (arr[middle] < n) first = middle + 1; else last = middle - 1; } return first; }; const getColor = (rating) => { if (rating < 400) return '#808080'; // gray else if (rating < 800) return '#804000'; // brown else if (rating < 1200) return '#008000'; // green else if (rating < 1600) return '#00C0C0'; // cyan else if (rating < 2000) return '#0000FF'; // blue else if (rating < 2400) return '#C0C000'; // yellow else if (rating < 2800) return '#FF8000'; // orange else if (rating == 9999) return '#000000'; return '#FF0000'; // red }; const formatTimespan = (sec) => { let sign; if (sec >= 0) { sign = ''; } else { sign = '-'; sec *= -1; } return `${sign}${Math.floor(sec / 60)}:${`0${sec % 60}`.slice(-2)}`; }; /** 現在のページから,コンテストの開始から終了までの秒数を抽出する */ const getContestDurationSec = () => { if (contestScreenName.startsWith('past')) { return 300 * 60; } // toDate.diff(fromDate) でミリ秒が返ってくる return endTime.diff(startTime) / 1000; }; const getCenterOfInnerRating = (contestScreenName) => { if (contestScreenName.startsWith('agc')) { const contestNumber = Number(contestScreenName.substring(3, 6)); return contestNumber >= 34 ? 1200 : 1600; } if (contestScreenName.startsWith('arc')) { const contestNumber = Number(contestScreenName.substring(3, 6)); return contestNumber >= 104 ? 1000 : 1600; } return 800; }; const rangeLen = (len) => Array.from({ length: len }, (v, k) => k); const BASE_URL = 'https://raw.githubusercontent.com/iilj/atcoder-standings-difficulty-analyzer/main/json/standings'; const fetchJson = async (url) => { const res = await fetch(url); if (!res.ok) { throw new Error(res.statusText); } const obj = (await res.json()); return obj; }; const fetchContestAcRatioModel = async (contestScreenName, contestDurationMinutes) => { // https://raw.githubusercontent.com/iilj/atcoder-standings-difficulty-analyzer/main/json/standings/abc_100m.json let modelLocation = undefined; if (/^agc(\d{3,})$/.exec(contestScreenName)) { if ([110, 120, 130, 140, 150, 160, 180, 200, 210, 240, 270, 300].includes(contestDurationMinutes)) { modelLocation = `${BASE_URL}/agc_${contestDurationMinutes}m.json`; } } else if (/^arc(\d{3,})$/.exec(contestScreenName)) { if ([100, 120, 150].includes(contestDurationMinutes)) { modelLocation = `${BASE_URL}/arc_${contestDurationMinutes}m.json`; } } else if (/^abc(\d{3,})$/.exec(contestScreenName)) { if ([100, 120].includes(contestDurationMinutes)) { modelLocation = `${BASE_URL}/abc_${contestDurationMinutes}m.json`; } } if (modelLocation !== undefined) { return await fetchJson(modelLocation); } return undefined; }; const fetchInnerRatingsFromPredictor = async (contestScreenName) => { const url = `https://data.ac-predictor.com/aperfs/${contestScreenName}.json`; try { return await fetchJson(url); } catch (e) { return {}; } }; class RatingConverter { } /** 表示用の低レート帯補正レート → 低レート帯補正前のレート */ RatingConverter.toRealRating = (correctedRating) => { if (correctedRating >= 400) return correctedRating; else return 400 * (1 - Math.log(400 / correctedRating)); }; /** 低レート帯補正前のレート → 内部レート推定値 */ RatingConverter.toInnerRating = (realRating, comp) => { return (realRating + (1200 * (Math.sqrt(1 - Math.pow(0.81, comp)) / (1 - Math.pow(0.9, comp)) - 1)) / (Math.sqrt(19) - 1)); }; /** 低レート帯補正前のレート → 表示用の低レート帯補正レート */ RatingConverter.toCorrectedRating = (realRating) => { if (realRating >= 400) return realRating; else return Math.floor(400 / Math.exp((400 - realRating) / 400)); }; class DifficultyCalculator { constructor(sortedInnerRatings) { this.innerRatings = sortedInnerRatings; this.prepared = new Map(); this.memo = new Map(); } perf2ExpectedAcceptedCount(m) { let expectedAcceptedCount; if (this.prepared.has(m)) { expectedAcceptedCount = this.prepared.get(m); } else { expectedAcceptedCount = this.innerRatings.reduce((prev_expected_accepts, innerRating) => (prev_expected_accepts += 1 / (1 + Math.pow(6, (m - innerRating) / 400))), 0); this.prepared.set(m, expectedAcceptedCount); } return expectedAcceptedCount; } perf2Ranking(x) { return this.perf2ExpectedAcceptedCount(x) + 0.5; } /** Difficulty 推定値を算出する */ binarySearch(acceptedCount) { if (this.memo.has(acceptedCount)) { return this.memo.get(acceptedCount); } let lb = -10000; let ub = 10000; while (ub - lb > 1) { const m = Math.floor((ub + lb) / 2); const expectedAcceptedCount = this.perf2ExpectedAcceptedCount(m); if (expectedAcceptedCount < acceptedCount) ub = m; else lb = m; } const difficulty = lb; const correctedDifficulty = RatingConverter.toCorrectedRating(difficulty); this.memo.set(acceptedCount, correctedDifficulty); return correctedDifficulty; } } var html$1 = "<div id=\"acssa-loader\" class=\"loader acssa-loader-wrapper\">\n <div class=\"loader-inner ball-pulse\">\n <div></div>\n <div></div>\n <div></div>\n </div>\n</div>\n<div id=\"acssa-chart-block\">\n <div class=\"acssa-chart-wrapper acssa-chart-wrapper-active\" id=\"acssa-mydiv-difficulty-wrapper\">\n <div id=\"acssa-mydiv-difficulty\" style=\"width:100%;\"></div>\n </div>\n <div class=\"acssa-chart-wrapper\" id=\"acssa-mydiv-accepted-count-wrapper\">\n <div id=\"acssa-mydiv-accepted-count\" style=\"width:100%;\"></div>\n </div>\n <div class=\"acssa-chart-wrapper\" id=\"acssa-mydiv-accepted-time-wrapper\">\n <div id=\"acssa-mydiv-accepted-time\" style=\"width:100%;\"></div>\n </div>\n</div>"; const LOADER_ID = 'acssa-loader'; const plotlyDifficultyChartId = 'acssa-mydiv-difficulty'; const plotlyAcceptedCountChartId = 'acssa-mydiv-accepted-count'; const plotlyLastAcceptedTimeChartId = 'acssa-mydiv-accepted-time'; const yourMarker = { size: 10, symbol: 'cross', color: 'red', line: { color: 'white', width: 1, }, }; const config = { autosize: true }; // 背景用設定 const alpha = 0.3; const colors = [ [0, 400, `rgba(128,128,128,${alpha})`], [400, 800, `rgba(128,0,0,${alpha})`], [800, 1200, `rgba(0,128,0,${alpha})`], [1200, 1600, `rgba(0,255,255,${alpha})`], [1600, 2000, `rgba(0,0,255,${alpha})`], [2000, 2400, `rgba(255,255,0,${alpha})`], [2400, 2800, `rgba(255,165,0,${alpha})`], [2800, 10000, `rgba(255,0,0,${alpha})`], ]; class Charts { constructor(parent, tasks, scoreLastAcceptedTimeMap, taskAcceptedCounts, taskAcceptedElapsedTimes, yourTaskAcceptedElapsedTimes, yourScore, yourLastAcceptedTime, participants, dc, tabs) { this.tasks = tasks; this.scoreLastAcceptedTimeMap = scoreLastAcceptedTimeMap; this.taskAcceptedCounts = taskAcceptedCounts; this.taskAcceptedElapsedTimes = taskAcceptedElapsedTimes; this.yourTaskAcceptedElapsedTimes = yourTaskAcceptedElapsedTimes; this.yourScore = yourScore; this.yourLastAcceptedTime = yourLastAcceptedTime; this.participants = participants; this.dc = dc; this.tabs = tabs; parent.insertAdjacentHTML('beforeend', html$1); this.duration = getContestDurationSec(); this.xtick = 60 * 10 * Math.max(1, Math.ceil(this.duration / (60 * 10 * 20))); // 10 分を最小単位にする } async plotAsync() { // 以降の計算は時間がかかる this.taskAcceptedElapsedTimes.forEach((ar) => { ar.sort((a, b) => a - b); }); // 時系列データの準備 const [difficultyChartData, acceptedCountChartData] = this.getTimeSeriesChartData(); // 得点と提出時間データの準備 const [lastAcceptedTimeChartData, maxAcceptedTime] = this.getLastAcceptedTimeChartData(); // 軸フォーマットをカスタムする this.overrideAxisFormat(); // Difficulty Chart 描画 await this.plotDifficultyChartData(difficultyChartData); // Accepted Count Chart 描画 await this.plotAcceptedCountChartData(acceptedCountChartData); // LastAcceptedTime Chart 描画 await this.plotLastAcceptedTimeChartData(lastAcceptedTimeChartData, maxAcceptedTime); } /** 時系列データの準備 */ getTimeSeriesChartData() { /** Difficulty Chart のデータ */ const difficultyChartData = []; /** AC Count Chart のデータ */ const acceptedCountChartData = []; for (let j = 0; j < this.tasks.length; ++j) { // const interval = Math.ceil(this.taskAcceptedCounts[j] / 140); const [taskAcceptedElapsedTimesForChart, taskAcceptedCountsForChart] = this.taskAcceptedElapsedTimes[j].reduce(([ar, arr], tm, idx) => { const tmpInterval = Math.max(1, Math.min(Math.ceil(idx / 10), interval)); if (idx % tmpInterval == 0 || idx == this.taskAcceptedCounts[j] - 1) { ar.push(tm); arr.push(idx + 1); } return [ar, arr]; }, [[], []]); difficultyChartData.push({ x: taskAcceptedElapsedTimesForChart, y: taskAcceptedCountsForChart.map((taskAcceptedCountForChart) => this.dc.binarySearch(taskAcceptedCountForChart)), type: 'scatter', name: `${this.tasks[j].Assignment}`, }); acceptedCountChartData.push({ x: taskAcceptedElapsedTimesForChart, y: taskAcceptedCountsForChart, type: 'scatter', name: `${this.tasks[j].Assignment}`, }); } // 現在のユーザのデータを追加 if (this.yourScore !== -1) { const yourAcceptedTimes = []; const yourAcceptedDifficulties = []; const yourAcceptedCounts = []; for (let j = 0; j < this.tasks.length; ++j) { if (this.yourTaskAcceptedElapsedTimes[j] !== -1) { yourAcceptedTimes.push(this.yourTaskAcceptedElapsedTimes[j]); const yourAcceptedCount = arrayLowerBound(this.taskAcceptedElapsedTimes[j], this.yourTaskAcceptedElapsedTimes[j]) + 1; yourAcceptedCounts.push(yourAcceptedCount); yourAcceptedDifficulties.push(this.dc.binarySearch(yourAcceptedCount)); } } this.tabs.yourDifficultyChartData = { x: yourAcceptedTimes, y: yourAcceptedDifficulties, mode: 'markers', type: 'scatter', name: `${userScreenName}`, marker: yourMarker, }; this.tabs.yourAcceptedCountChartData = { x: yourAcceptedTimes, y: yourAcceptedCounts, mode: 'markers', type: 'scatter', name: `${userScreenName}`, marker: yourMarker, }; difficultyChartData.push(this.tabs.yourDifficultyChartData); acceptedCountChartData.push(this.tabs.yourAcceptedCountChartData); } return [difficultyChartData, acceptedCountChartData]; } /** 得点と提出時間データの準備 */ getLastAcceptedTimeChartData() { const lastAcceptedTimeChartData = []; const scores = [...this.scoreLastAcceptedTimeMap.keys()]; scores.sort((a, b) => b - a); let acc = 0; let maxAcceptedTime = 0; scores.forEach((score) => { const lastAcceptedTimes = this.scoreLastAcceptedTimeMap.get(score); lastAcceptedTimes.sort((a, b) => a - b); const interval = Math.ceil(lastAcceptedTimes.length / 100); const lastAcceptedTimesForChart = lastAcceptedTimes.reduce((ar, tm, idx) => { if (idx % interval == 0 || idx == lastAcceptedTimes.length - 1) ar.push(tm); return ar; }, []); const lastAcceptedTimesRanks = lastAcceptedTimes.reduce((ar, tm, idx) => { if (idx % interval == 0 || idx == lastAcceptedTimes.length - 1) ar.push(acc + idx + 1); return ar; }, []); lastAcceptedTimeChartData.push({ x: lastAcceptedTimesRanks, y: lastAcceptedTimesForChart, type: 'scatter', name: `${score}`, }); if (score === this.yourScore) { const lastAcceptedTimesRank = arrayLowerBound(lastAcceptedTimes, this.yourLastAcceptedTime); this.tabs.yourLastAcceptedTimeChartData = { x: [acc + lastAcceptedTimesRank + 1], y: [this.yourLastAcceptedTime], mode: 'markers', type: 'scatter', name: `${userScreenName}`, marker: yourMarker, }; this.tabs.yourLastAcceptedTimeChartDataIndex = lastAcceptedTimeChartData.length + 0; lastAcceptedTimeChartData.push(this.tabs.yourLastAcceptedTimeChartData); } acc += lastAcceptedTimes.length; if (lastAcceptedTimes[lastAcceptedTimes.length - 1] > maxAcceptedTime) { maxAcceptedTime = lastAcceptedTimes[lastAcceptedTimes.length - 1]; } }); return [lastAcceptedTimeChartData, maxAcceptedTime]; } /** * 軸フォーマットをカスタムする * Support specifying a function for tickformat · Issue #1464 · plotly/plotly.js * https://github.com/plotly/plotly.js/issues/1464#issuecomment-498050894 */ overrideAxisFormat() { const org_locale = Plotly.d3.locale; Plotly.d3.locale = (locale) => { const result = org_locale(locale); // eslint-disable-next-line @typescript-eslint/unbound-method const org_number_format = result.numberFormat; result.numberFormat = (format) => { if (format != 'TIME') { return org_number_format(format); } return (x) => formatTimespan(x).toString(); }; return result; }; } /** Difficulty Chart 描画 */ async plotDifficultyChartData(difficultyChartData) { const maxAcceptedCount = this.taskAcceptedCounts.reduce((a, b) => Math.max(a, b)); const yMax = RatingConverter.toCorrectedRating(this.dc.binarySearch(1)); const yMin = RatingConverter.toCorrectedRating(this.dc.binarySearch(Math.max(2, maxAcceptedCount))); // 描画 const layout = { title: 'Difficulty', xaxis: { dtick: this.xtick, tickformat: 'TIME', range: [0, this.duration], // title: { text: 'Elapsed' } }, yaxis: { dtick: 400, tickformat: 'd', range: [ Math.max(0, Math.floor((yMin - 100) / 400) * 400), Math.max(0, Math.ceil((yMax + 100) / 400) * 400), ], // title: { text: 'Difficulty' } }, shapes: colors.map((c) => { return { type: 'rect', layer: 'below', xref: 'x', yref: 'y', x0: 0, x1: this.duration, y0: c[0], y1: c[1], line: { width: 0 }, fillcolor: c[2], }; }), margin: { b: 60, t: 30, }, }; await Plotly.newPlot(plotlyDifficultyChartId, difficultyChartData, layout, config); window.addEventListener('resize', () => { if (this.tabs.activeTab == 0) void Plotly.relayout(plotlyDifficultyChartId, { width: document.getElementById(plotlyDifficultyChartId).clientWidth, }); }); } /** Accepted Count Chart 描画 */ async plotAcceptedCountChartData(acceptedCountChartData) { this.tabs.acceptedCountYMax = this.participants; const rectSpans = colors.reduce((ar, cur) => { const bottom = this.dc.perf2ExpectedAcceptedCount(cur[1]); if (bottom > this.tabs.acceptedCountYMax) return ar; const top = cur[0] == 0 ? this.tabs.acceptedCountYMax : this.dc.perf2ExpectedAcceptedCount(cur[0]); if (top < 0.5) return ar; ar.push([Math.max(0.5, bottom), Math.min(this.tabs.acceptedCountYMax, top), cur[2]]); return ar; }, []); // 描画 const layout = { title: 'Accepted Count', xaxis: { dtick: this.xtick, tickformat: 'TIME', range: [0, this.duration], // title: { text: 'Elapsed' } }, yaxis: { // type: 'log', // dtick: 100, tickformat: 'd', range: [0, this.tabs.acceptedCountYMax], // range: [ // Math.log10(0.5), // Math.log10(acceptedCountYMax) // ], // title: { text: 'Difficulty' } }, shapes: rectSpans.map((span) => { return { type: 'rect', layer: 'below', xref: 'x', yref: 'y', x0: 0, x1: this.duration, y0: span[0], y1: span[1], line: { width: 0 }, fillcolor: span[2], }; }), margin: { b: 60, t: 30, }, }; await Plotly.newPlot(plotlyAcceptedCountChartId, acceptedCountChartData, layout, config); window.addEventListener('resize', () => { if (this.tabs.activeTab == 1) void Plotly.relayout(plotlyAcceptedCountChartId, { width: document.getElementById(plotlyAcceptedCountChartId).clientWidth, }); }); } /** LastAcceptedTime Chart 描画 */ async plotLastAcceptedTimeChartData(lastAcceptedTimeChartData, maxAcceptedTime) { const xMax = this.participants; const yMax = Math.ceil((maxAcceptedTime + this.xtick / 2) / this.xtick) * this.xtick; const rectSpans = colors.reduce((ar, cur) => { const right = cur[0] == 0 ? xMax : this.dc.perf2Ranking(cur[0]); if (right < 1) return ar; const left = this.dc.perf2Ranking(cur[1]); if (left > xMax) return ar; ar.push([Math.max(0, left), Math.min(xMax, right), cur[2]]); return ar; }, []); // console.log(colors); // console.log(rectSpans); const layout = { title: 'LastAcceptedTime v.s. Rank', xaxis: { // dtick: 100, tickformat: 'd', range: [0, xMax], // title: { text: 'Elapsed' } }, yaxis: { dtick: this.xtick, tickformat: 'TIME', range: [0, yMax], // range: [ // Math.max(0, Math.floor((yMin - 100) / 400) * 400), // Math.max(0, Math.ceil((yMax + 100) / 400) * 400) // ], // title: { text: 'Difficulty' } }, shapes: rectSpans.map((span) => { return { type: 'rect', layer: 'below', xref: 'x', yref: 'y', x0: span[0], x1: span[1], y0: 0, y1: yMax, line: { width: 0 }, fillcolor: span[2], }; }), margin: { b: 60, t: 30, }, }; await Plotly.newPlot(plotlyLastAcceptedTimeChartId, lastAcceptedTimeChartData, layout, config); window.addEventListener('resize', () => { if (this.tabs.activeTab == 2) void Plotly.relayout(plotlyLastAcceptedTimeChartId, { width: document.getElementById(plotlyLastAcceptedTimeChartId).clientWidth, }); }); } hideLoader() { document.getElementById(LOADER_ID).style.display = 'none'; } } /** レートを表す難易度円(◒)の HTML 文字列を生成 */ const generateDifficultyCircle = (rating, isSmall = true) => { const size = isSmall ? 12 : 36; const borderWidth = isSmall ? 1 : 3; const style = `display:inline-block;border-radius:50%;border-style:solid;border-width:${borderWidth}px;` + `margin-right:5px;vertical-align:initial;height:${size}px;width:${size}px;`; if (rating < 3200) { // 色と円がどのぐらい満ちているかを計算 const color = getColor(rating); const percentFull = ((rating % 400) / 400) * 100; // ◒を生成 return (` <span style='${style}border-color:${color};background:` + `linear-gradient(to top, ${color} 0%, ${color} ${percentFull}%, ` + `rgba(0, 0, 0, 0) ${percentFull}%, rgba(0, 0, 0, 0) 100%); '> </span>`); } // 金銀銅は例外処理 else if (rating < 3600) { return (`<span style="${style}border-color: rgb(150, 92, 44);` + 'background: linear-gradient(to right, rgb(150, 92, 44), rgb(255, 218, 189), rgb(150, 92, 44));"></span>'); } else if (rating < 4000) { return (`<span style="${style}border-color: rgb(128, 128, 128);` + 'background: linear-gradient(to right, rgb(128, 128, 128), white, rgb(128, 128, 128));"></span>'); } else { return (`<span style="${style}border-color: rgb(255, 215, 0);` + 'background: linear-gradient(to right, rgb(255, 215, 0), white, rgb(255, 215, 0));"></span>'); } }; const COL_PER_ROW = 20; class DifficyltyTable { constructor(parent, tasks, isEstimationEnabled, dc, taskAcceptedCounts, yourTaskAcceptedElapsedTimes, acCountPredicted) { // insert parent.insertAdjacentHTML('beforeend', ` <div id="acssa-table-wrapper"> ${rangeLen(Math.ceil(tasks.length / COL_PER_ROW)) .map((tableIdx) => ` <table id="acssa-table-${tableIdx}" class="table table-bordered table-hover th-center td-center td-middle acssa-table"> <tbody> <tr id="acssa-thead-${tableIdx}" class="acssa-thead"></tr> </tbody> <tbody> <tr id="acssa-tbody-${tableIdx}" class="acssa-tbody"></tr> ${isEstimationEnabled ? `<tr id="acssa-tbody-predicted-${tableIdx}" class="acssa-tbody"></tr>` : ''} </tbody> </table> `) .join('')} </div> `); if (isEstimationEnabled) { for (let tableIdx = 0; tableIdx < Math.ceil(tasks.length / COL_PER_ROW); ++tableIdx) { document.getElementById(`acssa-thead-${tableIdx}`).insertAdjacentHTML('beforeend', `<th></th>`); document.getElementById(`acssa-tbody-${tableIdx}`).insertAdjacentHTML('beforeend', `<th>Current</td>`); document.getElementById(`acssa-tbody-predicted-${tableIdx}`).insertAdjacentHTML('beforeend', `<th>Predicted</td>`); } } // build for (let j = 0; j < tasks.length; ++j) { const tableIdx = Math.floor(j / COL_PER_ROW); const correctedDifficulty = RatingConverter.toCorrectedRating(dc.binarySearch(taskAcceptedCounts[j])); const tdClass = yourTaskAcceptedElapsedTimes[j] === -1 ? '' : 'class="success acssa-task-success"'; document.getElementById(`acssa-thead-${tableIdx}`).insertAdjacentHTML('beforeend', ` <td ${tdClass}> ${tasks[j].Assignment} </td> `); const id = `td-assa-difficulty-${j}`; document.getElementById(`acssa-tbody-${tableIdx}`).insertAdjacentHTML('beforeend', ` <td ${tdClass} id="${id}" style="color:${getColor(correctedDifficulty)};"> ${correctedDifficulty === 9999 ? '-' : correctedDifficulty}</td> `); if (correctedDifficulty !== 9999) { document.getElementById(id).insertAdjacentHTML('afterbegin', generateDifficultyCircle(correctedDifficulty)); } if (isEstimationEnabled) { const correctedPredictedDifficulty = RatingConverter.toCorrectedRating(dc.binarySearch(acCountPredicted[j])); const idPredicted = `td-assa-difficulty-predicted-${j}`; document.getElementById(`acssa-tbody-predicted-${tableIdx}`).insertAdjacentHTML('beforeend', ` <td ${tdClass} id="${idPredicted}" style="color:${getColor(correctedPredictedDifficulty)};"> ${correctedPredictedDifficulty === 9999 ? '-' : correctedPredictedDifficulty}</td> `); if (correctedPredictedDifficulty !== 9999) { document.getElementById(idPredicted).insertAdjacentHTML('afterbegin', generateDifficultyCircle(correctedPredictedDifficulty)); } } } } } var html = "<div id=\"acssa-tab-wrapper\">\n <ul class=\"nav nav-pills small\" id=\"acssa-chart-tab\">\n <li class=\"active\">\n <a class=\"acssa-chart-tab-button\"><span class=\"glyphicon glyphicon-stats\" aria-hidden=\"true\"></span>Difficulty</a>\n </li>\n <li>\n <a class=\"acssa-chart-tab-button\"><span class=\"glyphicon glyphicon-stats\" aria-hidden=\"true\"></span>AC\n Count</a>\n </li>\n <li>\n <a class=\"acssa-chart-tab-button\"><span class=\"glyphicon glyphicon-stats\" aria-hidden=\"true\"></span>LastAcceptedTime</a>\n </li>\n </ul>\n <ul class=\"nav nav-pills\" id=\"acssa-checkbox-tab\">\n <li>\n <a><label><input type=\"checkbox\" id=\"acssa-checkbox-toggle-your-result-visibility\" checked=\"checked\"> Plot your\n result</label></a>\n </li>\n <li id=\"acssa-checkbox-toggle-log-plot-parent\">\n <a><label><input type=\"checkbox\" id=\"acssa-checkbox-toggle-log-plot\">Log plot</label></a>\n </li>\n </ul>\n</div>"; const TABS_WRAPPER_ID = 'acssa-tab-wrapper'; const CHART_TAB_ID = 'acssa-chart-tab'; const CHART_TAB_BUTTON_CLASS = 'acssa-chart-tab-button'; const CHECKBOX_TOGGLE_YOUR_RESULT_VISIBILITY = 'acssa-checkbox-toggle-your-result-visibility'; const CHECKBOX_TOGGLE_LOG_PLOT = 'acssa-checkbox-toggle-log-plot'; const PARENT_CHECKBOX_TOGGLE_LOG_PLOT = `${CHECKBOX_TOGGLE_LOG_PLOT}-parent`; class Tabs { constructor(parent, yourScore, participants) { this.yourScore = yourScore; this.participants = participants; // insert parent.insertAdjacentHTML('beforeend', html); this.showYourResultCheckbox = document.getElementById(CHECKBOX_TOGGLE_YOUR_RESULT_VISIBILITY); this.logPlotCheckbox = document.getElementById(CHECKBOX_TOGGLE_LOG_PLOT); this.logPlotCheckboxParent = document.getElementById(PARENT_CHECKBOX_TOGGLE_LOG_PLOT); // チェックボックス操作時のイベントを登録する */ this.showYourResultCheckbox.addEventListener('change', () => { if (this.showYourResultCheckbox.checked) { document.querySelectorAll('.acssa-task-success.acssa-task-success-suppress').forEach((elm) => { elm.classList.remove('acssa-task-success-suppress'); }); } else { document.querySelectorAll('.acssa-task-success').forEach((elm) => { elm.classList.add('acssa-task-success-suppress'); }); } }); this.showYourResultCheckbox.addEventListener('change', () => { void this.onShowYourResultCheckboxChangedAsync(); }); this.logPlotCheckbox.addEventListener('change', () => { void this.onLogPlotCheckboxChangedAsync(); }); this.activeTab = 0; this.showYourResult = [true, true, true]; this.acceptedCountYMax = -1; this.useLogPlot = [false, false, false]; this.yourDifficultyChartData = null; this.yourAcceptedCountChartData = null; this.yourLastAcceptedTimeChartData = null; this.yourLastAcceptedTimeChartDataIndex = -1; document .querySelectorAll(`.${CHART_TAB_BUTTON_CLASS}`) .forEach((btn, key) => { btn.addEventListener('click', () => void this.onTabButtonClicked(btn, key)); }); if (this.yourScore == -1) { // disable checkbox this.showYourResultCheckbox.checked = false; this.showYourResultCheckbox.disabled = true; const checkboxParent = this.showYourResultCheckbox.parentElement; checkboxParent.style.cursor = 'default'; checkboxParent.style.textDecoration = 'line-through'; } } async onShowYourResultCheckboxChangedAsync() { this.showYourResult[this.activeTab] = this.showYourResultCheckbox.checked; if (this.showYourResultCheckbox.checked) { // show switch (this.activeTab) { case 0: if (this.yourScore > 0 && this.yourDifficultyChartData !== null) await Plotly.addTraces(plotlyDifficultyChartId, this.yourDifficultyChartData); break; case 1: if (this.yourScore > 0 && this.yourAcceptedCountChartData !== null) await Plotly.addTraces(plotlyAcceptedCountChartId, this.yourAcceptedCountChartData); break; case 2: if (this.yourLastAcceptedTimeChartData !== null && this.yourLastAcceptedTimeChartDataIndex != -1) { await Plotly.addTraces(plotlyLastAcceptedTimeChartId, this.yourLastAcceptedTimeChartData, this.yourLastAcceptedTimeChartDataIndex); } break; } } else { // hide switch (this.activeTab) { case 0: if (this.yourScore > 0) await Plotly.deleteTraces(plotlyDifficultyChartId, -1); break; case 1: if (this.yourScore > 0) await Plotly.deleteTraces(plotlyAcceptedCountChartId, -1); break; case 2: if (this.yourLastAcceptedTimeChartDataIndex != -1) { await Plotly.deleteTraces(plotlyLastAcceptedTimeChartId, this.yourLastAcceptedTimeChartDataIndex); } break; } } } // end async onShowYourResultCheckboxChangedAsync() async onLogPlotCheckboxChangedAsync() { if (this.acceptedCountYMax == -1) return; this.useLogPlot[this.activeTab] = this.logPlotCheckbox.checked; if (this.activeTab == 1) { if (this.logPlotCheckbox.checked) { // log plot const layout = { yaxis: { type: 'log', range: [Math.log10(0.5), Math.log10(this.acceptedCountYMax)], }, }; await Plotly.relayout(plotlyAcceptedCountChartId, layout); } else { // linear plot const layout = { yaxis: { type: 'linear', range: [0, this.acceptedCountYMax], }, }; await Plotly.relayout(plotlyAcceptedCountChartId, layout); } } else if (this.activeTab == 2) { if (this.logPlotCheckbox.checked) { // log plot const layout = { xaxis: { type: 'log', range: [Math.log10(0.5), Math.log10(this.participants)], }, }; await Plotly.relayout(plotlyLastAcceptedTimeChartId, layout); } else { // linear plot const layout = { xaxis: { type: 'linear', range: [0, this.participants], }, }; await Plotly.relayout(plotlyLastAcceptedTimeChartId, layout); } } } // end async onLogPlotCheckboxChangedAsync async onTabButtonClicked(btn, key) { // check whether active or not const buttonParent = btn.parentElement; if (buttonParent.className == 'active') return; // modify visibility this.activeTab = key; document.querySelector(`#${CHART_TAB_ID} li.active`).classList.remove('active'); document.querySelector(`#${CHART_TAB_ID} li:nth-child(${key + 1})`).classList.add('active'); document.querySelector('#acssa-chart-block div.acssa-chart-wrapper-active').classList.remove('acssa-chart-wrapper-active'); document.querySelector(`#acssa-chart-block div.acssa-chart-wrapper:nth-child(${key + 1})`).classList.add('acssa-chart-wrapper-active'); // resize charts switch (key) { case 0: await Plotly.relayout(plotlyDifficultyChartId, { width: document.getElementById(plotlyDifficultyChartId).clientWidth, }); this.logPlotCheckboxParent.style.display = 'none'; break; case 1: await Plotly.relayout(plotlyAcceptedCountChartId, { width: document.getElementById(plotlyAcceptedCountChartId).clientWidth, }); this.logPlotCheckboxParent.style.display = 'block'; break; case 2: await Plotly.relayout(plotlyLastAcceptedTimeChartId, { width: document.getElementById(plotlyLastAcceptedTimeChartId).clientWidth, }); this.logPlotCheckboxParent.style.display = 'block'; break; } if (this.showYourResult[this.activeTab] !== this.showYourResultCheckbox.checked) { await this.onShowYourResultCheckboxChangedAsync(); } if (this.activeTab !== 0 && this.useLogPlot[this.activeTab] !== this.logPlotCheckbox.checked) { await this.onLogPlotCheckboxChangedAsync(); } } showTabsControl() { document.getElementById(TABS_WRAPPER_ID).style.display = 'block'; } } const NS2SEC = 1000000000; const CONTENT_DIV_ID = 'acssa-contents'; class Parent { constructor(acRatioModel) { const loaderStyles = GM_getResourceText('loaders.min.css'); GM_addStyle(loaderStyles + '\n' + css); this.centerOfInnerRating = getCenterOfInnerRating(contestScreenName); this.acRatioModel = acRatioModel; this.working = false; this.oldStandingsData = null; this.hasTeamStandings = this.searchTeamStandingsPage(); } searchTeamStandingsPage() { const teamStandingsLink = document.querySelector(`a[href*="/contests/${contestScreenName}/standings/team"]`); return teamStandingsLink !== null; } async onStandingsChanged(standings) { if (!standings) return; if (this.working) return; this.tasks = standings.TaskInfo; const standingsData = standings.StandingsData; // vueStandings.filteredStandings; if (this.oldStandingsData === standingsData) return; if (this.tasks.length === 0) return; this.oldStandingsData = standingsData; this.working = true; this.removeOldContents(); const currentTime = moment(); this.elapsedMinutes = Math.floor(currentTime.diff(startTime) / 60 / 1000); this.isDuringContest = startTime <= currentTime && currentTime < endTime; this.isEstimationEnabled = this.isDuringContest && this.elapsedMinutes >= 1 && this.tasks.length < 10; this.innerRatingsFromPredictor = await fetchInnerRatingsFromPredictor(contestScreenName); this.scanStandingsData(standingsData); this.predictAcCountSeries(); const standingsElement = document.getElementById('vue-standings'); const acssaContentDiv = document.createElement('div'); acssaContentDiv.id = CONTENT_DIV_ID; standingsElement.insertAdjacentElement('afterbegin', acssaContentDiv); if (this.hasTeamStandings) { if (!location.href.includes('/standings/team')) { // チーム戦順位表へ誘導 acssaContentDiv.insertAdjacentHTML('afterbegin', teamalert); } } // difficulty new DifficyltyTable(acssaContentDiv, this.tasks, this.isEstimationEnabled, this.dc, this.taskAcceptedCounts, this.yourTaskAcceptedElapsedTimes, this.acCountPredicted); // tabs const tabs = new Tabs(acssaContentDiv, this.yourScore, this.participants); const charts = new Charts(acssaContentDiv, this.tasks, this.scoreLastAcceptedTimeMap, this.taskAcceptedCounts, this.taskAcceptedElapsedTimes, this.yourTaskAcceptedElapsedTimes, this.yourScore, this.yourLastAcceptedTime, this.participants, this.dc, tabs); // 順位表のその他の描画を優先するために,プロットは後回しにする window.setTimeout(() => { void charts.plotAsync().then(() => { charts.hideLoader(); tabs.showTabsControl(); this.working = false; }); }, 100); } removeOldContents() { const oldContents = document.getElementById(CONTENT_DIV_ID); if (oldContents) { // oldContents.parentNode.removeChild(oldContents); oldContents.remove(); } } scanStandingsData(standingsData) { // init this.scoreLastAcceptedTimeMap = new Map(); this.taskAcceptedCounts = rangeLen(this.tasks.length).fill(0); this.taskAcceptedElapsedTimes = rangeLen(this.tasks.length).map(() => []); this.innerRatings = []; this.yourTaskAcceptedElapsedTimes = rangeLen(this.tasks.length).fill(-1); this.yourScore = -1; this.yourLastAcceptedTime = -1; this.participants = 0; // scan for (let i = 0; i < standingsData.length; ++i) { const standingsEntry = standingsData[i]; if (!standingsEntry.TaskResults) continue; // 参加登録していない if (standingsEntry.UserIsDeleted) continue; // アカウント削除 // let correctedRating = this.isDuringContest ? standingsEntry.Rating : standingsEntry.OldRating; let correctedRating = standingsEntry.Rating; const isTeamOrBeginner = correctedRating === 0; if (isTeamOrBeginner) { // continue; // 初参加 or チーム correctedRating = this.centerOfInnerRating; } // これは飛ばしちゃダメ(提出しても 0 AC だと Penalty == 0 なので) // if (standingsEntry.TotalResult.Score == 0 && standingsEntry.TotalResult.Penalty == 0) continue; let score = 0; let penalty = 0; for (let j = 0; j < this.tasks.length; ++j) { const taskResultEntry = standingsEntry.TaskResults[this.tasks[j].TaskScreenName]; if (!taskResultEntry) continue; // 未提出 score += taskResultEntry.Score; penalty += taskResultEntry.Score === 0 ? taskResultEntry.Failure : taskResultEntry.Penalty; } if (score === 0 && penalty === 0) continue; // NoSub を飛ばす this.participants++; // console.log(i + 1, score, penalty); score /= 100; if (this.scoreLastAcceptedTimeMap.has(score)) { this.scoreLastAcceptedTimeMap.get(score).push(standingsEntry.TotalResult.Elapsed / NS2SEC); } else { this.scoreLastAcceptedTimeMap.set(score, [standingsEntry.TotalResult.Elapsed / NS2SEC]); } const innerRating = isTeamOrBeginner ? correctedRating : standingsEntry.UserScreenName in this.innerRatingsFromPredictor ? this.innerRatingsFromPredictor[standingsEntry.UserScreenName] : RatingConverter.toInnerRating(Math.max(RatingConverter.toRealRating(correctedRating), 1), standingsEntry.Competitions); // console.log(this.isDuringContest, standingsEntry.Rating, standingsEntry.OldRating, innerRating); if (innerRating) this.innerRatings.push(innerRating); else { console.log(i, innerRating, correctedRating, standingsEntry.Competitions); continue; } for (let j = 0; j < this.tasks.length; ++j) { const taskResultEntry = standingsEntry.TaskResults[this.tasks[j].TaskScreenName]; const isAccepted = (taskResultEntry === null || taskResultEntry === void 0 ? void 0 : taskResultEntry.Score) > 0 && (taskResultEntry === null || taskResultEntry === void 0 ? void 0 : taskResultEntry.Status) == 1; if (isAccepted) { ++this.taskAcceptedCounts[j]; this.taskAcceptedElapsedTimes[j].push(taskResultEntry.Elapsed / NS2SEC); } } if (standingsEntry.UserScreenName == userScreenName) { this.yourScore = score; this.yourLastAcceptedTime = standingsEntry.TotalResult.Elapsed / NS2SEC; for (let j = 0; j < this.tasks.length; ++j) { const taskResultEntry = standingsEntry.TaskResults[this.tasks[j].TaskScreenName]; const isAccepted = (taskResultEntry === null || taskResultEntry === void 0 ? void 0 : taskResultEntry.Score) > 0 && (taskResultEntry === null || taskResultEntry === void 0 ? void 0 : taskResultEntry.Status) == 1; if (isAccepted) { this.yourTaskAcceptedElapsedTimes[j] = taskResultEntry.Elapsed / NS2SEC; } } } } // end for this.innerRatings.sort((a, b) => a - b); this.dc = new DifficultyCalculator(this.innerRatings); } // end async scanStandingsData predictAcCountSeries() { if (!this.isEstimationEnabled) { this.acCountPredicted = []; return; } // 時間ごとの AC 数推移を計算する const taskAcceptedCountImos = rangeLen(this.tasks.length).map(() => rangeLen(this.elapsedMinutes).map(() => 0)); this.taskAcceptedElapsedTimes.forEach((ar, index) => { ar.forEach((seconds) => { const minutes = Math.floor(seconds / 60); if (minutes >= this.elapsedMinutes) return; taskAcceptedCountImos[index][minutes] += 1; }); }); const taskAcceptedRatio = rangeLen(this.tasks.length).map(() => []); taskAcceptedCountImos.forEach((ar, index) => { let cum = 0; ar.forEach((imos) => { cum += imos; taskAcceptedRatio[index].push(cum / this.participants); }); }); // 差の自乗和が最小になるシーケンスを探す this.acCountPredicted = taskAcceptedRatio.map((ar) => { if (this.acRatioModel === undefined) return 0; if (ar[this.elapsedMinutes - 1] === 0) return 0; let minerror = 1.0 * this.elapsedMinutes; // let argmin = ''; let last_ratio = 0; Object.keys(this.acRatioModel).forEach((key) => { if (this.acRatioModel === undefined) return; const ar2 = this.acRatioModel[key]; let error = 0; for (let i = 0; i < this.elapsedMinutes; ++i) { error += Math.pow(ar[i] - ar2[i], 2); } if (error < minerror) { minerror = error; // argmin = key; if (ar2[this.elapsedMinutes - 1] > 0) { last_ratio = ar2[ar2.length - 1] * (ar[this.elapsedMinutes - 1] / ar2[this.elapsedMinutes - 1]); } else { last_ratio = ar2[ar2.length - 1]; } } }); // console.log(argmin, minerror, last_ratio); if (last_ratio > 1) last_ratio = 1; return this.participants * last_ratio; }); } // end predictAcCountSeries(); } Parent.init = async () => { const curr = moment(); if (startTime <= curr && curr < endTime) { const contestDurationMinutes = endTime.diff(startTime) / 1000 / 60; return new Parent(await fetchContestAcRatioModel(contestScreenName, contestDurationMinutes)); } else { return new Parent(undefined); } }; void (async () => { const parent = await Parent.init(); vueStandings.$watch('standings', (standings) => { void parent.onStandingsChanged(standings); }, { deep: true, immediate: true }); })();