atcoder-standings-difficulty-analyzer

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

2021-04-29 기준 버전입니다. 최신 버전을 확인하세요.

// ==UserScript==
// @name         atcoder-standings-difficulty-analyzer
// @namespace    iilj
// @version      2021.4.30.0
// @description  順位表の得点情報を集計し,推定 difficulty やその推移を表示します.
// @author       iilj
// @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==

/**
 * 問題ごとの結果エントリ
 * @typedef {Object} TaskResultEntry
 * @property {any} Additional 謎
 * @property {number} Count 提出回数
 * @property {number} Elapsed コンテスト開始からの経過時間 [ns].
 * @property {number} Failure 非 AC の提出数(ACするまではペナルティではない).
 * @property {boolean} Frozen アカウントが凍結済みかどうか?
 * @property {number} Penalty ペナルティ数
 * @property {boolean} Pending ジャッジ中かどうか?
 * @property {number} Score 得点(×100)
 * @property {number} Status 1 のとき満点? 6 のとき部分点?
 */

/**
 * 全問題の結果
 * @typedef {Object} TotalResultEntry
 * @property {number} Accepted 正解した問題数
 * @property {any} Additional 謎
 * @property {number} Count 提出回数
 * @property {number} Elapsed コンテスト開始からの経過時間 [ns].
 * @property {boolean} Frozen アカウントが凍結済みかどうか?
 * @property {number} Penalty ペナルティ数
 * @property {number} Score 得点(×100)
 */

/**
 * 順位表エントリ
 * @typedef {Object} StandingsEntry
 * @property {any} Additional 謎
 * @property {string} Affiliation 所属.IsTeam = true のときは,チームメンバを「, 」で結合した文字列.
 * @property {number} AtCoderRank AtCoder 内順位
 * @property {number} Competitions Rated コンテスト参加回数
 * @property {string} Country 国ラベル."JP" など.
 * @property {string} DisplayName 表示名."hitonanode" など.
 * @property {number} EntireRank コンテスト順位?
 * @property {boolean} IsRated Rated かどうか
 * @property {boolean} IsTeam チームかどうか
 * @property {number} OldRating コンテスト前のレーティング.コンテスト後のみ有効.
 * @property {number} Rank コンテスト順位?
 * @property {number} Rating コンテスト後のレーティング
 * @property {{[key: string]: TaskResultEntry}} TaskResults 問題ごとの結果.参加登録していない人は空.
 * @property {TotalResultEntry} TotalResult 全体の結果
 * @property {boolean} UserIsDeleted ユーザアカウントが削除済みかどうか
 * @property {string} UserName ユーザ名."hitonanode" など.
 * @property {string} UserScreenName ユーザの表示名."hitonanode" など.
 */

/**
 * 問題エントリ
 * @typedef {Object} TaskInfoEntry
 * @property {string} Assignment 問題ラベル."A" など.
 * @property {string} TaskName 問題名.
 * @property {string} TaskScreenName 問題の slug. "abc185_a" など.
 */

/**
 * 順位表情報
 * @typedef {Object} Standings
 * @property {any} AdditionalColumns 謎
 * @property {boolean} Fixed 謎
 * @property {StandingsEntry[]} StandingsData 順位表データ
 * @property {TaskInfoEntry[]} TaskInfo 問題データ
 */

/* globals vueStandings, $, contestScreenName, startTime, endTime, userScreenName, Plotly */

(() => {
    'use strict';

    // loader のスタイル設定
    const loaderStyles = GM_getResourceText("loaders.min.css");
    const loaderWrapperStyles = `
.acssa-table {
    width: 100%;
    table-layout: fixed;
    margin-bottom: 1.5rem;
}
.acssa-thead {
    font-weight: bold;
}
.acssa-loader-wrapper {
    background-color: #337ab7;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 1rem;
    margin-bottom: 1.5rem;
    border-radius: 3px;
}
#acssa-tab-wrapper {
    display: none;
}
#acssa-chart-tab, #acssa-checkbox-tab {
    margin-bottom: 0.5rem;
    display: inline-block;
}
#acssa-chart-tab a, #acssa-checkbox-tab label, #acssa-checkbox-tab label input {
    cursor: pointer;
}
#acssa-chart-tab span.glyphicon {
    margin-right: 0.5rem;
}
#acssa-checkbox-tab label, #acssa-checkbox-tab input {
    margin: 0;
}
#acssa-checkbox-tab li a {
    color: black;
}
#acssa-checkbox-tab li a:hover {
    background-color: transparent;
}
.acssa-chart-wrapper {
    display: none;
}
.acssa-chart-wrapper.acssa-chart-wrapper-active {
    display: block;
}
.acssa-task-checked {
    color: green;
    margin-left: 0.5rem;
}
#acssa-checkbox-toggle-log-plot-parent {
    display: none;
}
    `;
    GM_addStyle(loaderStyles + loaderWrapperStyles);

    class RatingConverter {
        /** 表示用の低レート帯補正レート → 低レート帯補正前のレート
         * @type {(correctedRating: number) => number} */
        static toRealRating = (correctedRating) => {
            if (correctedRating >= 400) return correctedRating;
            else return 400 * (1 - Math.log(400 / correctedRating));
        };

        /** 低レート帯補正前のレート → 内部レート推定値
         * @type {(correctedRating: number) => number} */
        static 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);
        };

        /** 低レート帯補正前のレート → 表示用の低レート帯補正レート
         * @type {(correctedRating: number) => number} */
        static toCorrectedRating = (realRating) => {
            if (realRating >= 400) return realRating;
            else return Math.floor(400 / Math.exp((400 - realRating) / 400));
        };
    }

    class DifficultyCalculator {
        /** @constructor
         * @type {(sortedInnerRatings: number[]) => DifficultyCalculator}
         */
        constructor(sortedInnerRatings) {
            this.innerRatings = sortedInnerRatings;
            /** @type {Map<number, number>} */
            this.prepared = new Map();
            /** @type {Map<number, number>} */
            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) => this.perf2ExpectedAcceptedCount(x) + 0.5;

        /** Difficulty 推定値を算出する
         *  @type {((acceptedCount: number) => number)} */
        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;
        };
    }

    /** @type {(ar: number[], n: number) => number} */
    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;
    };

    /** @type {(rating: number) => string} */
    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
    };

    /** レートを表す難易度円(◒)の HTML 文字列を生成
     *  @type {(rating: number, isSmall?: boolean) => string} */
    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>';
        }
    }

    /** @type {(sec: number) => string} */
    const formatTimespan = (sec) => {
        let sign;
        if (sec >= 0) {
            sign = "";
        } else {
            sign = "-";
            sec *= -1;
        }
        return `${sign}${Math.floor(sec / 60)}:${`0${sec % 60}`.slice(-2)}`;
    };

    /** 現在のページから,コンテストの開始から終了までの秒数を抽出する
     * @type {() => number}
     */
    const getContestDurationSec = () => {
        if (contestScreenName.startsWith("past")) {
            return 300 * 60;
        }
        return (endTime - startTime) / 1000;
    };

    /** @type {(contestScreenName: string) => number} */
    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 centerOfInnerRating = getCenterOfInnerRating(contestScreenName);

    let working = false;
    let oldStandingsData = null;

    /** 順位表更新時の処理:テーブル追加
     *  @type {(v: Standings) => void} */
    const onStandingsChanged = async (standings) => {
        if (!standings) return;
        if (working) return;

        const tasks = standings.TaskInfo;
        const standingsData = standings.StandingsData; // vueStandings.filteredStandings;

        if (oldStandingsData === standingsData) return;
        oldStandingsData = standingsData;
        working = true;
        // console.log(standings);

        { // remove old contents
            const oldContents = document.getElementById("acssa-contents");
            if (oldContents) {
                // oldContents.parentNode.removeChild(oldContents);
                oldContents.remove();
            }
        }

        /** 問題ごとの最終 AC 時刻リスト.
         * @type {Map<number, number[]>} */
        const scoreLastAcceptedTimeMap = new Map();

        // コンテスト中かどうか判別する
        let isDuringContest = true;
        for (let i = 0; i < standingsData.length; ++i) {
            const standingsEntry = standingsData[i];
            if (standingsEntry.OldRating > 0) {
                isDuringContest = false;
                break;
            }
        }

        /** 各問題の正答者数.
         * @type {number[]} */
        const taskAcceptedCounts = Array(tasks.length);
        taskAcceptedCounts.fill(0);

        /** 各問題の正答時間リスト.秒単位で格納する.
         * @type {number[][]} */
        const taskAcceptedElapsedTimes = [...Array(tasks.length)].map((_, i) => []);
        // taskAcceptedElapsedTimes.fill([]); // これだと同じインスタンスで埋めてしまう

        /** 内部レートのリスト.
         * @type {number[]} */
        const innerRatings = [];

        const NS2SEC = 1000000000;

        /** @type {{[key: string]: number}} */
        const innerRatingsFromPredictor = await (async () => {
            try {
                const res = await fetch(`https://data.ac-predictor.com/aperfs/${contestScreenName}.json`);
                if (res.ok) return await res.json();
            } catch (e) {
                console.warn(e);
            }
            return {};
        })();

        /** 現在のユーザの各問題の AC 時刻.
         * @type {number[]} */
        const yourTaskAcceptedElapsedTimes = Array(tasks.length);
        yourTaskAcceptedElapsedTimes.fill(-1);
        /** 現在のユーザのスコア */
        let yourScore = -1;
        /** 現在のユーザの最終 AC 時刻 */
        let yourLastAcceptedTime = -1;

        // 順位表情報を走査する(内部レートのリストと正答時間リストを構築する)
        let participants = 0;
        for (let i = 0; i < standingsData.length; ++i) {
            const standingsEntry = standingsData[i];

            if (!standingsEntry.TaskResults) continue; // 参加登録していない
            if (standingsEntry.UserIsDeleted) continue; // アカウント削除
            let correctedRating = isDuringContest ? standingsEntry.Rating : standingsEntry.OldRating;
            const isTeamOrBeginner = (correctedRating === 0);
            if (isTeamOrBeginner) {
                // continue; // 初参加 or チーム
                correctedRating = 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 < tasks.length; ++j) {
                const taskResultEntry = standingsEntry.TaskResults[tasks[j].TaskScreenName];
                if (!taskResultEntry) continue; // 未提出
                score += taskResultEntry.Score;
                penalty += (taskResultEntry.Score === 0 ? taskResultEntry.Failure : taskResultEntry.Penalty);
            }
            if (score === 0 && penalty === 0) continue; // NoSub を飛ばす
            participants++;
            // console.log(i + 1, score, penalty);

            score /= 100;
            if (scoreLastAcceptedTimeMap.has(score)) {
                scoreLastAcceptedTimeMap.get(score).push(standingsEntry.TotalResult.Elapsed / NS2SEC)
            } else {
                scoreLastAcceptedTimeMap.set(score, [standingsEntry.TotalResult.Elapsed / NS2SEC]);
            }

            const innerRating = isTeamOrBeginner
                ? correctedRating
                : (standingsEntry.UserScreenName in innerRatingsFromPredictor)
                    ? innerRatingsFromPredictor[standingsEntry.UserScreenName]
                    : RatingConverter.toInnerRating(
                        Math.max(RatingConverter.toRealRating(correctedRating), 1), standingsEntry.Competitions);
            if (innerRating) innerRatings.push(innerRating);
            else {
                console.log(i, innerRating, correctedRating, standingsEntry.Competitions);
                continue;
            }
            for (let j = 0; j < tasks.length; ++j) {
                const taskResultEntry = standingsEntry.TaskResults[tasks[j].TaskScreenName];
                const isAccepted = (taskResultEntry?.Score > 0 && taskResultEntry?.Status == 1);
                if (isAccepted) {
                    ++taskAcceptedCounts[j];
                    taskAcceptedElapsedTimes[j].push(taskResultEntry.Elapsed / NS2SEC);
                }
            }
            if (standingsEntry.UserScreenName == userScreenName) {
                yourScore = score;
                yourLastAcceptedTime = standingsEntry.TotalResult.Elapsed / NS2SEC;
                for (let j = 0; j < tasks.length; ++j) {
                    const taskResultEntry = standingsEntry.TaskResults[tasks[j].TaskScreenName];
                    const isAccepted = (taskResultEntry?.Score > 0 && taskResultEntry?.Status == 1);
                    if (isAccepted) {
                        yourTaskAcceptedElapsedTimes[j] = taskResultEntry.Elapsed / NS2SEC;
                    }
                }
            }
        }
        innerRatings.sort((a, b) => a - b);

        const dc = new DifficultyCalculator(innerRatings);

        const plotlyDifficultyChartId = 'acssa-mydiv-difficulty';
        const plotlyAcceptedCountChartId = 'acssa-mydiv-accepted-count';
        const plotlyLastAcceptedTimeChartId = 'acssa-mydiv-accepted-time';
        const COL_PER_ROW = 20;
        $('#vue-standings').prepend(`
        <div id="acssa-contents">
          ${[...Array(Math.ceil(tasks.length / COL_PER_ROW)).keys()].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>
            </tbody>
          </table>
          `).join('')}
          <div id="acssa-tab-wrapper">
            <ul class="nav nav-pills small" id="acssa-chart-tab">
                <li class="active">
                <a class="acssa-chart-tab-button"><span class="glyphicon glyphicon-stats" aria-hidden="true"></span>Difficulty</a></li>
                <li>
                <a class="acssa-chart-tab-button"><span class="glyphicon glyphicon-stats" aria-hidden="true"></span>AC Count</a></li>
                <li>
                <a class="acssa-chart-tab-button"><span class="glyphicon glyphicon-stats" aria-hidden="true"></span>LastAcceptedTime</a></li>
            </ul>
            <ul class="nav nav-pills" id="acssa-checkbox-tab">
              <li>
                <a><label><input type="checkbox" id="acssa-checkbox-toggle-your-result-visibility" checked> Plot your result</label></a></li>
              <li id="acssa-checkbox-toggle-log-plot-parent">
                <a><label><input type="checkbox" id="acssa-checkbox-toggle-log-plot">Log plot</label></a></li>
            </ul>
          </div>
          <div id="acssa-loader" class="loader acssa-loader-wrapper">
            <div class="loader-inner ball-pulse">
                <div></div>
                <div></div>
                <div></div>
            </div>
          </div>
          <div id="acssa-chart-block">
            <div class="acssa-chart-wrapper acssa-chart-wrapper-active" id="${plotlyDifficultyChartId}-wrapper">
                <div id="${plotlyDifficultyChartId}" style="width:100%;"></div>
            </div>
            <div class="acssa-chart-wrapper" id="${plotlyAcceptedCountChartId}-wrapper">
                <div id="${plotlyAcceptedCountChartId}" style="width:100%;"></div>
            </div>
            <div class="acssa-chart-wrapper" id="${plotlyLastAcceptedTimeChartId}-wrapper">
                <div id="${plotlyLastAcceptedTimeChartId}" style="width:100%;"></div>
            </div>
          </div>
        </div>
        `);

        // チェックボックス操作時のイベントを登録する
        /** @type {HTMLInputElement} */
        const checkbox = document.getElementById("acssa-checkbox-toggle-your-result-visibility");
        checkbox.addEventListener("change", () => {
            if (checkbox.checked) {
                document.querySelectorAll('.acssa-task-checked').forEach(elm => {
                    elm.style.display = 'inline';
                });
            } else {
                document.querySelectorAll('.acssa-task-checked').forEach(elm => {
                    elm.style.display = 'none';
                });
            }
        });

        let activeTab = 0;
        const showYourResult = [true, true, true];

        let yourDifficultyChartData = null;
        let yourAcceptedCountChartData = null;
        let yourLastAcceptedTimeChartData = null;
        let yourLastAcceptedTimeChartDataIndex = -1;
        const onCheckboxChanged = () => {
            showYourResult[activeTab] = checkbox.checked;
            if (checkbox.checked) {
                // show
                switch (activeTab) {
                    case 0:
                        if (yourScore > 0) Plotly.addTraces(plotlyDifficultyChartId, yourDifficultyChartData);
                        break;
                    case 1:
                        if (yourScore > 0) Plotly.addTraces(plotlyAcceptedCountChartId, yourAcceptedCountChartData);
                        break;
                    case 2:
                        if (yourLastAcceptedTimeChartDataIndex != -1) {
                            Plotly.addTraces(plotlyLastAcceptedTimeChartId, yourLastAcceptedTimeChartData, yourLastAcceptedTimeChartDataIndex);
                        }
                        break;
                    default:
                        break;
                }
            } else {
                // hide
                switch (activeTab) {
                    case 0:
                        if (yourScore > 0) Plotly.deleteTraces(plotlyDifficultyChartId, -1);
                        break;
                    case 1:
                        if (yourScore > 0) Plotly.deleteTraces(plotlyAcceptedCountChartId, -1);
                        break;
                    case 2:
                        if (yourLastAcceptedTimeChartDataIndex != -1) {
                            Plotly.deleteTraces(plotlyLastAcceptedTimeChartId, yourLastAcceptedTimeChartDataIndex);
                        }
                        break;
                    default:
                        break;
                }
            }
        };

        /** @type {HTMLInputElement} */
        const logPlotCheckbox = document.getElementById('acssa-checkbox-toggle-log-plot');
        const logPlotCheckboxParent = document.getElementById('acssa-checkbox-toggle-log-plot-parent');

        let acceptedCountYMax = -1;
        const useLogPlot = [false, false, false];
        const onLogPlotCheckboxChanged = () => {
            if (acceptedCountYMax == -1) return;
            useLogPlot[activeTab] = logPlotCheckbox.checked;
            if (activeTab == 1) {
                if (logPlotCheckbox.checked) {
                    // log plot
                    const layout = {
                        yaxis: {
                            type: 'log',
                            range: [
                                Math.log10(0.5),
                                Math.log10(acceptedCountYMax)
                            ],
                        },
                    };
                    Plotly.relayout(plotlyAcceptedCountChartId, layout);
                } else {
                    // linear plot
                    const layout = {
                        yaxis: {
                            type: 'linear',
                            range: [
                                0,
                                acceptedCountYMax
                            ],
                        },
                    };
                    Plotly.relayout(plotlyAcceptedCountChartId, layout);
                }
            } else if (activeTab == 2) {
                if (logPlotCheckbox.checked) {
                    // log plot
                    const layout = {
                        xaxis: {
                            type: 'log',
                            range: [
                                Math.log10(0.5),
                                Math.log10(participants)
                            ],
                        },
                    };
                    Plotly.relayout(plotlyLastAcceptedTimeChartId, layout);
                } else {
                    // linear plot
                    const layout = {
                        xaxis: {
                            type: 'linear',
                            range: [
                                0,
                                participants
                            ],
                        },
                    };
                    Plotly.relayout(plotlyLastAcceptedTimeChartId, layout);
                }
            }
        };

        document.querySelectorAll(".acssa-chart-tab-button").forEach((btn, key) => {
            btn.addEventListener("click", () => {
                // check whether active or not
                if (btn.parentElement.className == "active") return;
                // modify visibility
                activeTab = key;
                document.querySelector("#acssa-chart-tab li.active").classList.remove("active");
                document.querySelector(`#acssa-chart-tab 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:
                        Plotly.relayout(plotlyDifficultyChartId, { width: document.getElementById(plotlyDifficultyChartId).clientWidth });
                        logPlotCheckboxParent.style.display = 'none';
                        break;
                    case 1:
                        Plotly.relayout(plotlyAcceptedCountChartId, { width: document.getElementById(plotlyAcceptedCountChartId).clientWidth });
                        logPlotCheckboxParent.style.display = 'block';
                        break;
                    case 2:
                        Plotly.relayout(plotlyLastAcceptedTimeChartId, { width: document.getElementById(plotlyLastAcceptedTimeChartId).clientWidth });
                        logPlotCheckboxParent.style.display = 'block';
                        break;
                    default:
                        break;
                }
                if (showYourResult[activeTab] !== checkbox.checked) {
                    onCheckboxChanged();
                }
                if (activeTab !== 0 && useLogPlot[activeTab] !== logPlotCheckbox.checked) {
                    onLogPlotCheckboxChanged();
                }
            });
        });

        logPlotCheckbox.addEventListener('change', onLogPlotCheckboxChanged);

        // 現在の Difficulty テーブルを構築する
        for (let j = 0; j < tasks.length; ++j) {
            const tableIdx = Math.floor(j / COL_PER_ROW);
            const correctedDifficulty = RatingConverter.toCorrectedRating(dc.binarySearch(taskAcceptedCounts[j]));
            document.getElementById(`acssa-thead-${tableIdx}`).insertAdjacentHTML("beforeend", `
                <td>
                  ${tasks[j].Assignment}
                  ${yourTaskAcceptedElapsedTimes[j] === -1 ? '' : '<span class="acssa-task-checked">✓</span>'}
                </td>
            `);
            const id = `td-assa-difficulty-${j}`;
            document.getElementById(`acssa-tbody-${tableIdx}`).insertAdjacentHTML("beforeend", `
                <td id="${id}" style="color:${getColor(correctedDifficulty)};">
                ${correctedDifficulty === 9999 ? '-' : correctedDifficulty}</td>
            `);
            if (correctedDifficulty !== 9999) {
                document.getElementById(id).insertAdjacentHTML(
                    "afterbegin", generateDifficultyCircle(correctedDifficulty));
            }
        }

        if (yourScore == -1) {
            // disable checkbox
            checkbox.checked = false;
            checkbox.disabled = true;
            checkbox.parentElement.style.cursor = 'default';
            checkbox.parentElement.style.textDecoration = 'line-through';
        }

        // 順位表のその他の描画を優先するために,後回しにする
        setTimeout(() => {
            const maxAcceptedCount = taskAcceptedCounts.reduce((a, b) => Math.max(a, b));
            const yMax = RatingConverter.toCorrectedRating(dc.binarySearch(1));
            const yMin = RatingConverter.toCorrectedRating(dc.binarySearch(Math.max(2, maxAcceptedCount)));

            // 以降の計算は時間がかかる

            taskAcceptedElapsedTimes.forEach(ar => {
                ar.sort((a, b) => a - b);
            });

            // 時系列データの準備
            /** Difficulty Chart のデータ
             * @type {{x: number, y: number, type: string, name: string}[]} */
            const difficultyChartData = [];
            /** AC Count Chart のデータ
             * @type {{x: number, y: number, type: string, name: string}[]} */
            const acceptedCountChartData = [];

            for (let j = 0; j < tasks.length; ++j) { // 
                const interval = Math.ceil(taskAcceptedCounts[j] / 140);
                /** @type {[number[], number[]]} */
                const [taskAcceptedElapsedTimesForChart, taskAcceptedCountsForChart] = taskAcceptedElapsedTimes[j].reduce(
                    ([ar, arr], tm, idx) => {
                        const tmpInterval = Math.max(1, Math.min(Math.ceil(idx / 10), interval));
                        if (idx % tmpInterval == 0 || idx == taskAcceptedCounts[j] - 1) {
                            ar.push(tm);
                            arr.push(idx + 1);
                        }
                        return [ar, arr];
                    },
                    [[], []]
                );

                difficultyChartData.push({
                    x: taskAcceptedElapsedTimesForChart,
                    y: taskAcceptedCountsForChart.map(taskAcceptedCountForChart => dc.binarySearch(taskAcceptedCountForChart)),
                    type: 'scatter',
                    name: `${tasks[j].Assignment}`,
                });
                acceptedCountChartData.push({
                    x: taskAcceptedElapsedTimesForChart,
                    y: taskAcceptedCountsForChart,
                    type: 'scatter',
                    name: `${tasks[j].Assignment}`,
                });
            }

            // 現在のユーザのデータを追加
            const yourMarker = {
                size: 10,
                symbol: "cross",
                color: 'red',
                line: {
                    color: 'white',
                    width: 1,
                },
            };
            if (yourScore !== -1) {
                /** @type {number[]} */
                const yourAcceptedTimes = [];
                /** @type {number[]} */
                const yourAcceptedDifficulties = [];
                /** @type {number[]} */
                const yourAcceptedCounts = [];

                for (let j = 0; j < tasks.length; ++j) {
                    if (yourTaskAcceptedElapsedTimes[j] !== -1) {
                        yourAcceptedTimes.push(yourTaskAcceptedElapsedTimes[j]);
                        const yourAcceptedCount = arrayLowerBound(taskAcceptedElapsedTimes[j], yourTaskAcceptedElapsedTimes[j]) + 1;
                        yourAcceptedCounts.push(yourAcceptedCount);
                        yourAcceptedDifficulties.push(dc.binarySearch(yourAcceptedCount));
                    }
                }

                yourDifficultyChartData = {
                    x: yourAcceptedTimes,
                    y: yourAcceptedDifficulties,
                    mode: 'markers',
                    type: 'scatter',
                    name: `${userScreenName}`,
                    marker: yourMarker,
                };
                yourAcceptedCountChartData = {
                    x: yourAcceptedTimes,
                    y: yourAcceptedCounts,
                    mode: 'markers',
                    type: 'scatter',
                    name: `${userScreenName}`,
                    marker: yourMarker,
                };
                difficultyChartData.push(yourDifficultyChartData);
                acceptedCountChartData.push(yourAcceptedCountChartData);
            }

            // 得点と提出時間データの準備
            /** @type {{x: number, y: number, type: string, name: string}[]} */
            const lastAcceptedTimeChartData = [];
            const scores = [...scoreLastAcceptedTimeMap.keys()];
            scores.sort((a, b) => b - a);
            let acc = 0;
            let maxAcceptedTime = 0;
            scores.forEach(score => {
                const lastAcceptedTimes = scoreLastAcceptedTimeMap.get(score);
                lastAcceptedTimes.sort((a, b) => a - b);
                const interval = Math.ceil(lastAcceptedTimes.length / 100);
                /** @type {number[]} */
                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 === yourScore) {
                    const lastAcceptedTimesRank = arrayLowerBound(lastAcceptedTimes, yourLastAcceptedTime);
                    yourLastAcceptedTimeChartData = {
                        x: [acc + lastAcceptedTimesRank + 1],
                        y: [yourLastAcceptedTime],
                        mode: 'markers',
                        type: 'scatter',
                        name: `${userScreenName}`,
                        marker: yourMarker,
                    };
                    yourLastAcceptedTimeChartDataIndex = lastAcceptedTimeChartData.length + 0;
                    lastAcceptedTimeChartData.push(yourLastAcceptedTimeChartData);
                }

                acc += lastAcceptedTimes.length;
                if (lastAcceptedTimes[lastAcceptedTimes.length - 1] > maxAcceptedTime) {
                    maxAcceptedTime = lastAcceptedTimes[lastAcceptedTimes.length - 1];
                }
            });

            const duration = getContestDurationSec();
            const xtick = (60 * 10) * Math.max(1, Math.ceil(duration / (60 * 10 * 20))); // 10 分を最小単位にする

            // 軸フォーマットをカスタムする
            // Support specifying a function for tickformat · Issue #1464 · plotly/plotly.js
            // https://github.com/plotly/plotly.js/issues/1464#issuecomment-498050894
            {
                const org_locale = Plotly.d3.locale;
                Plotly.d3.locale = (locale) => {
                    const result = org_locale(locale);
                    const org_number_format = result.numberFormat;
                    result.numberFormat = (format) => {
                        if (format != 'TIME') {
                            return org_number_format(format)
                        }
                        return (x) => formatTimespan(x).toString();
                    }
                    return result;
                };
            }

            // 背景用設定
            const alpha = 0.3;
            /** @type {[number, number, string][]} */
            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})`],
            ];

            // Difficulty Chart 描画
            {
                // 描画
                const layout = {
                    title: 'Difficulty',
                    xaxis: {
                        dtick: xtick,
                        tickformat: 'TIME',
                        range: [0, 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: duration,
                            y0: c[0],
                            y1: c[1],
                            line: { width: 0 },
                            fillcolor: c[2]
                        };
                    }),
                    margin: {
                        b: 60,
                        t: 30,
                    }
                };
                const config = { autosize: true };
                Plotly.newPlot(plotlyDifficultyChartId, difficultyChartData, layout, config);

                window.addEventListener('resize', () => {
                    if (activeTab == 0)
                        Plotly.relayout(plotlyDifficultyChartId, { width: document.getElementById(plotlyDifficultyChartId).clientWidth });
                });
            }

            // Accepted Count Chart 描画
            {
                acceptedCountYMax = participants;
                /** @type {[number, number, string][]} */
                const rectSpans = colors.reduce((ar, cur) => {
                    const bottom = dc.perf2ExpectedAcceptedCount(cur[1]);
                    if (bottom > acceptedCountYMax) return ar;
                    const top = (cur[0] == 0) ? acceptedCountYMax : dc.perf2ExpectedAcceptedCount(cur[0]);
                    if (top < 0.5) return ar;
                    ar.push([Math.max(0.5, bottom), Math.min(acceptedCountYMax, top), cur[2]]);
                    return ar;
                }, []);
                // 描画
                const layout = {
                    title: 'Accepted Count',
                    xaxis: {
                        dtick: xtick,
                        tickformat: 'TIME',
                        range: [0, duration],
                        // title: { text: 'Elapsed' }
                    },
                    yaxis: {
                        // type: 'log',
                        // dtick: 100,
                        tickformat: 'd',
                        range: [
                            0,
                            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: duration,
                            y0: span[0],
                            y1: span[1],
                            line: { width: 0 },
                            fillcolor: span[2]
                        };
                    }),
                    margin: {
                        b: 60,
                        t: 30,
                    }
                };
                const config = { autosize: true };
                Plotly.newPlot(plotlyAcceptedCountChartId, acceptedCountChartData, layout, config);

                window.addEventListener('resize', () => {
                    if (activeTab == 1)
                        Plotly.relayout(plotlyAcceptedCountChartId, { width: document.getElementById(plotlyAcceptedCountChartId).clientWidth });
                });
            }

            // LastAcceptedTime Chart 描画
            {
                const xMax = participants;
                const yMax = Math.ceil((maxAcceptedTime + xtick / 2) / xtick) * xtick;
                /** @type {[number, number, string][]} */
                const rectSpans = colors.reduce((ar, cur) => {
                    const right = (cur[0] == 0) ? xMax : dc.perf2Ranking(cur[0]);
                    if (right < 1) return ar;
                    const left = 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: 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,
                    }
                };
                const config = { autosize: true };
                Plotly.newPlot(plotlyLastAcceptedTimeChartId, lastAcceptedTimeChartData, layout, config);

                window.addEventListener('resize', () => {
                    if (activeTab == 2)
                        Plotly.relayout(plotlyLastAcceptedTimeChartId, { width: document.getElementById(plotlyLastAcceptedTimeChartId).clientWidth });
                });
            }

            // 現在のユーザの結果表示・非表示 toggle
            checkbox.addEventListener('change', onCheckboxChanged);

            document.getElementById('acssa-loader').style.display = 'none';
            document.getElementById('acssa-tab-wrapper').style.display = 'block';
            working = false;
        }, 100); // end setTimeout()
    };

    // MAIN
    vueStandings.$watch('standings', onStandingsChanged, { deep: true, immediate: true });

})();