OMCStandingsStatistics

onlinemathcontestでの /standings ページに補助情報を追加します

// ==UserScript==
// @name         OMCStandingsStatistics
// @namespace    none
// @version      1.0.2
// @description  onlinemathcontestでの /standings ページに補助情報を追加します
// @match        https://onlinemathcontest.com/contests/*/standings
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  // ------------------------------------------
  // /standings ページのURLパターン
  // ------------------------------------------
  const standingsPattern = /^https:\/\/onlinemathcontest\.com\/contests\/[^/]+\/standings$/;

  if (standingsPattern.test(location.href)) {
    // コンテスト名をURLから抜き出す
    const match = location.href.match(/contests\/([^/]+)\/standings/);
    if (!match) return;
    const contestName = match[1];
    const apiUrl = `https://onlinemathcontest.com/api/contests/${contestName}/standings?rated=0`;

    fetch(apiUrl)
      .then(res => res.json())
      .then(json => {
        const standingsDiv = document.getElementById('standings');
        if (!standingsDiv) {
          console.warn('#standingsが見つかりません');
          return;
        }
        const tasks = json.tasks;
        const standings = json.standings;
        const isPast = json.isPast;
        const isPointVisible = json.is_point_visible;

        // isPast かつ is_point_visible のときのみテーブルを表示
        if(isPast && isPointVisible){
          // テーブルを作成し、standingsDivの最上位に挿入
          const table = document.createElement('table');
          table.style.width = '100%';
          table.style.borderCollapse = 'collapse';
          table.innerHTML = `
            <thead>
              <tr>
                <th style="border:1px solid #ccc; padding:4px;">問題</th>
                <th style="border:1px solid #ccc; padding:4px;">得点</th>
                <th style="border:1px solid #ccc; padding:4px;">人数</th>
                <th style="border:1px solid #ccc; padding:4px;">正解率</th>
                <th style="border:1px solid #ccc; padding:4px;">平均ペナ</th>
                <th style="border:1px solid #ccc; padding:4px;">ペナ率</th>
                <th style="border:1px solid #ccc; padding:4px; width:200px;">レート</th>
              </tr>
            </thead>
            <tbody></tbody>
          `;
          standingsDiv.insertBefore(table, standingsDiv.firstChild);

          const tbody = table.querySelector('tbody');

          // レート→色 (ただし r=0 で黒)
          const rateColors = [
            { min: 1,    max: 400,    color: '#808080' },
            { min: 400,  max: 800,    color: '#804000' },
            { min: 800,  max: 1200,   color: '#008000' },
            { min: 1200, max: 1600,   color: '#00c0c0' },
            { min: 1600, max: 2000,   color: '#0000ff' },
            { min: 2000, max: 2400,   color: '#c0c000' },
            { min: 2400, max: 2800,   color: '#ff8000' },
            { min: 2800, max: 999999, color: '#ff0000' },
          ];

          tasks.forEach((task, idx) => {
            const label = String.fromCharCode('A'.charCodeAt(0) + idx);
            // タスクへのリンク
            const taskUrl = `https://onlinemathcontest.com/contests/${contestName}/tasks/${task.id}`;

            // 集計用
            let triedCount = 0;
            let solvedCount = 0;
            let penSum = 0;
            let penNonZero = 0;
            // レート分布のカウント
            const rateCounter = Array(rateColors.length).fill(0);
            // レート0 (None含む) のユーザーをカウントするための変数
            let countBlack = 0;

            standings.forEach(userStand => {
              const t = userStand.tasks?.[idx];
              if (!t) return;

              const tim = t.time;         // 整数 or null
              const pen = t.penalty;      // 整数 or null
              // userStand.user?.rate がnull/undefinedの場合は0と同じ扱い
              const r   = userStand.user?.rate ?? 0;

              // 提出ずみかどうか
              const tried = (tim != null) || (pen != null && pen >= 1);
              if (tried) {
                triedCount++;
              }

              // CA(正解)したかどうか
              if (tim != null) {
                solvedCount++;
                const realPen = pen ?? 0;
                penSum += realPen;
                if (realPen >= 1) {
                  penNonZero++;
                }
                // レートが0なら黒にする
                if (r === 0) {
                  countBlack++;
                } else {
                  // レートごとにカウント
                  for (let i = 0; i < rateColors.length; i++){
                    if (r >= rateColors[i].min && r < rateColors[i].max) {
                      rateCounter[i]++;
                      break;
                    }
                  }
                }
              }
            });

            // 表示用の文字列
            let peopleText = '0/0';
            let accPct = '0.00%';
            let avgPen = '0.00';
            let penPct = '0.00%';

            if (triedCount > 0) {
              peopleText = `${solvedCount}/${triedCount}`;
              const accuracy = (solvedCount / triedCount) * 100;
              accPct = accuracy.toFixed(2) + '%';
            }
            if (solvedCount > 0) {
              avgPen = (penSum / solvedCount).toFixed(2);
              const penRate = (penNonZero / solvedCount) * 100;
              penPct = penRate.toFixed(2) + '%';
            }

            const tr = document.createElement('tr');
            // ここで問題ラベルをリンク化
            tr.innerHTML = `
              <td style="border:1px solid #ccc; padding:4px;">
                <a href="${taskUrl}">
                  ${label}
                </a>
              </td>
              <td style="border:1px solid #ccc; padding:4px;">${task.admin_point ?? '-'}</td>
              <td style="border:1px solid #ccc; padding:4px;">${peopleText}</td>
              <td style="border:1px solid #ccc; padding:4px;">${accPct}</td>
              <td style="border:1px solid #ccc; padding:4px;">${avgPen}</td>
              <td style="border:1px solid #ccc; padding:4px;">${penPct}</td>
            `;

            // レート分布の棒グラフ
            const tdRate = document.createElement('td');
            tdRate.style.border = '1px solid #ccc';
            tdRate.style.padding = '4px';
            tdRate.style.width = '200px';

            const barDiv = document.createElement('div');
            barDiv.style.width = '100%';
            barDiv.style.height = '16px';
            barDiv.style.display = 'flex';
            barDiv.style.border = '1px solid #ccc';

            if (solvedCount > 0) {
              // まずレート0 (None) のユーザーを描画
              if (countBlack > 0) {
                const ratio = (countBlack / solvedCount) * 100;
                const segBlack = document.createElement('div');
                segBlack.style.width = ratio + '%';
                segBlack.style.backgroundColor = '#000000'; // 黒
                barDiv.appendChild(segBlack);
              }

              // それ以外の既存レート分布を描画
              for (let i = 0; i < rateCounter.length; i++){
                const c = rateCounter[i];
                if (!c) continue;
                const ratio = (c / solvedCount) * 100;
                const seg = document.createElement('div');
                seg.style.width = ratio + '%';
                seg.style.backgroundColor = rateColors[i].color;
                barDiv.appendChild(seg);
              }
            }

            tdRate.appendChild(barDiv);
            tr.appendChild(tdRate);
            tbody.appendChild(tr);
          });
        }
      })
      .catch(err => {
        console.error('Standings API取得失敗:', err);
      });
  }
})();