AtCoder Perf Overlay

Show ac-predictor performance in the bottom-left corner with cache, color, and no overlap.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==UserScript==
// @name         AtCoder Perf Overlay
// @namespace    https://github.com/yourname
// @version      1.3.0
// @description  Show ac-predictor performance in the bottom-left corner with cache, color, and no overlap.
// @author       yourname
// @name:ja    AtCoder Perf Overlay
// @license MIT
// @description:ja ac-predictorのパフォーマンスを右下に常時表示します。
// @match        https://atcoder.jp/contests/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(() => {
  'use strict';

  if (window.top !== window.self) return;

  const UPDATE_INTERVAL = 30_000;
  const WAIT_TIMEOUT = 8_000;
  const POLL_INTERVAL = 250;
  const BASE_BOTTOM = 14;
  const GAP = 8;
  const STORAGE_PREFIX = 'atcoder-perf-overlay:v4';

  const contestId = getContestId();
  const username = getUsername();

  if (!contestId || !username) return;

  const cacheKey = `${STORAGE_PREFIX}:${contestId}:${username}`;
  const overlay = createOverlay();

  let updateRunning = false;
  let standingsFrame = null;
  let standingsFramePromise = null;
  let cachedRankOverlay = null;

  function getContestId() {
    const m = location.pathname.match(/^\/contests\/([^/]+)/);
    return m ? m[1] : null;
  }

  function getUsername() {
    const a = document.querySelector('a[href^="/users/"]');
    if (!a) return null;

    const href = a.getAttribute('href') || '';
    const m = href.match(/^\/users\/([^/]+)/);
    return m ? m[1] : null;
  }

  function hexToRgba(hex, alpha) {
    const m = hex.replace('#', '').match(/^([0-9a-f]{6})$/i);
    if (!m) return `rgba(0,0,0,${alpha})`;

    const n = parseInt(m[1], 16);
    const r = (n >> 16) & 255;
    const g = (n >> 8) & 255;
    const b = n & 255;
    return `rgba(${r}, ${g}, ${b}, ${alpha})`;
  }

  // 色とレート帯の下限・上限を返す
  function getPerfTier(perf) {
    if (perf >= 2800) return { color: '#FF0000', lower: 2800, upper: Infinity };
    if (perf >= 2400) return { color: '#FF8000', lower: 2400, upper: 2800 };
    if (perf >= 2000) return { color: '#C0C000', lower: 2000, upper: 2400 };
    if (perf >= 1600) return { color: '#0000FF', lower: 1600, upper: 2000 };
    if (perf >= 1200) return { color: '#00C0C0', lower: 1200, upper: 1600 };
    if (perf >= 800)  return { color: '#008000', lower: 800,  upper: 1200 };
    if (perf >= 400)  return { color: '#804000', lower: 400,  upper: 800 };
    return { color: '#808080', lower: 0, upper: 400 };
  }

  function createOverlay() {
    const el = document.createElement('div');
    el.id = 'atcoder-perf-overlay';

    Object.assign(el.style, {
      position: 'fixed',
      left: '14px',
      bottom: `${BASE_BOTTOM}px`,
      zIndex: '2147483647',
      minWidth: '168px',
      maxWidth: '260px',
      padding: '10px 14px',
      borderRadius: '12px',
      boxSizing: 'border-box',
      color: '#111',
      background: '#fff',
      border: '1px solid rgba(0, 0, 0, 0.08)',
      boxShadow: '0 8px 24px rgba(0,0,0,0.22)',
      backdropFilter: 'blur(6px)',
      WebkitBackdropFilter: 'blur(6px)',
      fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
      fontSize: '14px',
      fontWeight: '600',
      lineHeight: '1.2',
      userSelect: 'none',
      pointerEvents: 'none',
      whiteSpace: 'nowrap',
      transition: 'background 0.2s ease, border-color 0.2s ease, bottom 0.2s ease, box-shadow 0.2s ease',
      fontVariantNumeric: 'tabular-nums',
    });

    document.body.appendChild(el);
    return el;
  }

  function render(perf, updating = false) {
    if (typeof perf !== 'number' || !Number.isFinite(perf)) {
      overlay.style.background = '#fff';
      overlay.style.borderColor = 'rgba(0, 0, 0, 0.08)';
      overlay.style.boxShadow = '0 8px 24px rgba(0,0,0,0.22)';

      overlay.innerHTML = `
        <div style="display:flex;align-items:center;gap:8px;">
          <span style="font-size:15px;font-weight:800;color:#111;">Perf: --</span>
          <span style="
            display:inline-block;
            width:78px;
            flex-shrink:0;
            font-size:11px;
            opacity:0;
          ">Updating...</span>
        </div>
      `;
      syncPlacement();
      return;
    }

    const tier = getPerfTier(perf);
    const color = tier.color;

    // レート帯内での位置を 0%~100% で計算
    let percentage;
    if (tier.upper === Infinity) {
      percentage = 100;
    } else {
      percentage = ((perf - tier.lower) / (tier.upper - tier.lower)) * 100;
      percentage = Math.max(0, Math.min(100, percentage));
    }

    // グラデーションを維持しつつ、色が percentage% まで支配的になり、そこから20%かけて白へフェード
    const fadeStart = percentage;
    const fadeEnd = Math.min(fadeStart + 20, 100);

    const colorStrong = hexToRgba(color, 0.20);
    const colorWeak = hexToRgba(color, 0.14);
    const whiteNear = 'rgba(255, 255, 255, 0.98)';
    const whiteFar = 'rgba(255, 255, 255, 0.94)';

    overlay.style.background = `
      linear-gradient(
        135deg,
        ${colorStrong} 0%,
        ${colorWeak} ${fadeStart}%,
        ${whiteNear} ${fadeEnd}%,
        ${whiteFar} 100%
      )
    `.trim();

    overlay.style.borderColor = hexToRgba(color, 0.35);
    overlay.style.boxShadow = `0 8px 24px ${hexToRgba(color, 0.10)}, 0 8px 24px rgba(0,0,0,0.22)`;

    overlay.innerHTML = `
      <div style="display:flex;align-items:center;gap:8px;">
        <span style="font-size:15px;font-weight:800;color:${color};">
          Perf: ${perf}
        </span>

        <span style="
          display:inline-block;
          width:78px;
          flex-shrink:0;
          font-size:11px;
          opacity:${updating ? '0.78' : '0'};
          transition:opacity 0.15s ease;
          text-align:left;
          color:#555;
        ">Updating...</span>
      </div>
    `;

    syncPlacement();
  }

  function loadCache() {
    try {
      const raw = localStorage.getItem(cacheKey);
      if (!raw) return null;

      const parsed = JSON.parse(raw);
      if (!parsed || typeof parsed !== 'object') return null;
      if (typeof parsed.perf !== 'number' || !Number.isFinite(parsed.perf)) return null;

      return parsed;
    } catch {
      return null;
    }
  }

  function saveCache(perf) {
    try {
      localStorage.setItem(
        cacheKey,
        JSON.stringify({
          perf,
          updatedAt: Date.now(),
        })
      );
    } catch {
      // ignore
    }
  }

  function isStandingsPage() {
    return /^\/contests\/[^/]+\/standings(?:\/extended)?\/?$/.test(location.pathname);
  }

  // 検索結果をキャッシュして重複検索を減らす
  function detectRankOverlay() {
    if (cachedRankOverlay && document.contains(cachedRankOverlay)) {
      return cachedRankOverlay;
    }

    const explicit = document.getElementById('atcoder-rank-overlay');
    if (explicit instanceof HTMLElement) {
      cachedRankOverlay = explicit;
      return explicit;
    }

    const candidates = [...document.querySelectorAll('div')];

    for (const el of candidates) {
      if (!(el instanceof HTMLElement)) continue;
      const text = (el.textContent || '').trim();
      if (!text.includes('Rank:')) continue;

      const style = getComputedStyle(el);
      if (style.position !== 'fixed') continue;
      if (style.left !== '14px') continue;

      cachedRankOverlay = el;
      return el;
    }

    return null;
  }

  function syncPlacement() {
    const rankOverlay = detectRankOverlay();

    if (!rankOverlay) {
      overlay.style.bottom = `${BASE_BOTTOM}px`;
      return;
    }

    const rect = rankOverlay.getBoundingClientRect();
    const targetBottom = BASE_BOTTOM + Math.ceil(rect.height) + GAP;
    overlay.style.bottom = `${targetBottom}px`;
  }

  function ensureStandingsFrame(urlPath) {
    if (standingsFrame && document.contains(standingsFrame) && standingsFrame.getAttribute('data-path') === urlPath) {
      return standingsFramePromise || Promise.resolve(standingsFrame);
    }

    if (standingsFrame && document.contains(standingsFrame)) {
      standingsFrame.remove();
      standingsFrame = null;
      standingsFramePromise = null;
    }

    standingsFramePromise = new Promise((resolve, reject) => {
      const iframe = document.createElement('iframe');
      iframe.setAttribute('aria-hidden', 'true');
      iframe.setAttribute('data-path', urlPath);

      Object.assign(iframe.style, {
        position: 'fixed',
        left: '0',
        top: '0',
        width: '1px',
        height: '1px',
        opacity: '0',
        pointerEvents: 'none',
        visibility: 'hidden',
        border: '0',
      });

      const timeout = window.setTimeout(() => {
        reject(new Error('standings iframe timeout'));
      }, WAIT_TIMEOUT);

      iframe.addEventListener('load', () => {
        window.clearTimeout(timeout);
        resolve(iframe);
      }, { once: true });

      iframe.src = urlPath;
      document.body.appendChild(iframe);
      standingsFrame = iframe;
    });

    return standingsFramePromise;
  }

  function extractPerfFromDocument(doc) {
    if (!doc) return null;

    const rows = [...doc.querySelectorAll('tbody tr')];
    for (const row of rows) {
      const usernameNode = row.querySelector('.standings-username .username span');
      const rowName = usernameNode?.textContent?.trim();
      if (rowName !== username) continue;

      const perfCell =
        row.querySelector('td.ac-predictor-standings-elem') ||
        row.querySelector('.ac-predictor-standings-elem');

      if (!perfCell) return null;

      const text = (perfCell.textContent || '').trim();
      const n = Number.parseInt(text.replace(/[^\d-]/g, ''), 10);
      return Number.isFinite(n) ? n : null;
    }

    return null;
  }

  async function waitForPerf(docGetter) {
    const startedAt = Date.now();

    while (Date.now() - startedAt < WAIT_TIMEOUT) {
      const doc = docGetter();
      const perf = extractPerfFromDocument(doc);
      if (perf != null) return perf;

      await new Promise((r) => setTimeout(r, POLL_INTERVAL));
    }

    return null;
  }

  async function fetchPerf() {
    if (isStandingsPage()) {
      const perf = await waitForPerf(() => document);
      if (perf != null) return perf;
    }

    const contestPath = `/contests/${contestId}/standings`;
    try {
      await ensureStandingsFrame(contestPath);
      const perf = await waitForPerf(() => standingsFrame?.contentDocument || null);
      if (perf != null) return perf;
    } catch {
      // fall through
    }

    const extendedPath = `/contests/${contestId}/standings/extended`;
    try {
      await ensureStandingsFrame(extendedPath);
      const perf = await waitForPerf(() => standingsFrame?.contentDocument || null);
      if (perf != null) return perf;
    } catch {
      // ignore
    }

    return null;
  }

  async function update() {
    if (updateRunning) return;
    updateRunning = true;

    try {
      const cached = loadCache();

      if (cached) {
        render(cached.perf, true);
      } else {
        render(null, false);
      }

      const perf = await fetchPerf();

      if (perf == null) {
        if (!cached) render(null, false);
        return;
      }

      saveCache(perf);
      render(perf, false);
    } finally {
      updateRunning = false;
    }
  }

  function init() {
    render(loadCache()?.perf ?? null, false);
    update();

    window.addEventListener('resize', syncPlacement, { passive: true });
    window.addEventListener('scroll', syncPlacement, { passive: true });

    window.setInterval(syncPlacement, 500);
    window.setInterval(update, UPDATE_INTERVAL);
  }

  init();
})();