Greasy Fork is available in English.
Show ac-predictor performance in the bottom-left corner with cache, color, and no overlap.
// ==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();
})();