Score graph for Kaggle submissions & leaderboard
// ==UserScript==
// @name Kaggle Score Chart
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Score graph for Kaggle submissions & leaderboard
// @author ren255
// @match https://www.kaggle.com/*
// @grant none
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ── Debug logger ───────────────────────────────────────────────────────────
const LOG = (...a) => console.log('[KSC]', ...a);
const WARN = (...a) => console.warn('[KSC]', ...a);
LOG('Script loaded. pathname =', location.pathname, '| href =', location.href);
// ═══════════════════════════════════════════════════════════════════════════
// PAGE CONFIGS
// ═══════════════════════════════════════════════════════════════════════════
const PAGES = {
submissions: {
urlPattern: /\/competitions\/[^/]+\/submissions/,
wrapId: 'ks-sub-wrap',
sel: {
name: '.hwJapD',
score: '.gaYcsl',
time: '.SGzzp span:nth-child(2)',
insertParent: '.boMSGK',
},
scrape(sel) {
const nameEls = document.querySelectorAll(sel.name);
const scoreEls = document.querySelectorAll(sel.score);
const timeEls = document.querySelectorAll(sel.time);
const rows = [];
const len = Math.min(nameEls.length, scoreEls.length);
for (let i = 0; i < len; i++) {
const score = parseFloat(scoreEls[i + 1]?.textContent?.trim());
if (isNaN(score)) continue;
rows.push({
label: nameEls[i]?.textContent?.trim() || `#${i + 1}`,
score,
timeLabel: timeEls[i]?.textContent?.trim() || '',
index: i + 1,
});
}
LOG(`scrape(submissions): parsed ${rows.length} rows`);
return rows.reverse();
},
tooltipLine1: r => `<b style="color:VAR_ACCENT">Submission #${r.index}</b>`,
},
leaderboard: {
urlPattern: /\/competitions\/[^/]+\/leaderboard/,
wrapId: 'ks-lb-wrap',
sel: {
name: '.kbQyFD',
score: '.cVLOrN span',
time: '.kxyJcc span',
insertParent: '.btOrhB',
},
scrape(sel) {
const nameEls = document.querySelectorAll(sel.name);
const scoreEls = document.querySelectorAll(sel.score);
const timeEls = document.querySelectorAll(sel.time);
LOG(`scrape(leaderboard): name=${nameEls.length} score=${scoreEls.length} time=${timeEls.length}`);
const rows = [];
const len = Math.min(49, nameEls.length, scoreEls.length);
for (let i = 0; i < len; i++) {
const score = parseFloat(scoreEls[i]?.textContent?.trim());
if (isNaN(score)) continue;
rows.push({
label: nameEls[i]?.textContent?.trim() || `#${i + 1}`,
score,
timeLabel: timeEls[i * 2]?.textContent?.trim() || '',
index: i + 1,
});
}
LOG(`scrape(leaderboard): parsed ${rows.length} rows`);
return rows;
},
tooltipLine1: r => `<b style="color:VAR_ACCENT">Rank #${r.index}</b>`,
},
};
// ═══════════════════════════════════════════════════════════════════════════
// THEME
// ═══════════════════════════════════════════════════════════════════════════
const CSS_VARS = {
'--ks-bg': '#f8f9fa',
'--ks-axis': '#343a40',
'--ks-grid': '#dee2e6',
'--ks-best-line': '#0d6efd',
'--ks-dot': '#adb5bd',
'--ks-best-dot': '#198754',
'--ks-active-ring': '#0d6efd',
'--ks-tip-bg': '#ffffff',
'--ks-tip-border': '#ced4da',
'--ks-tip-text': '#212529',
'--ks-btn-bg': '#e9ecef',
'--ks-btn-border': '#ced4da',
};
// ═══════════════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════════════
function parseHoursAgo(label) {
if (!label || /just|now/i.test(label)) return 0;
const m = label.match(/(\d+)\s*([smhdw])/i);
if (!m) return 0;
const n = +m[1], u = m[2].toLowerCase();
return { s: n / 3600, m: n / 60, h: n, d: n * 24, w: n * 168 }[u] || 0;
}
function fmtTime(h) {
if (h < 1) return `${Math.round(h * 60)}m`;
if (h < 24) return `${h.toFixed(1)}h`;
return `${(h / 24).toFixed(1)}d`;
}
function niceStep(range, lines = 5) {
const rough = range / lines;
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
const norm = rough / mag;
return (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag;
}
function cssVar(el, name) {
return getComputedStyle(el).getPropertyValue(name).trim();
}
const TIME_WINDOWS = { '1d': 24, '1w': 168, '1mo': 720, 'all': Infinity };
// ═══════════════════════════════════════════════════════════════════════════
// GRAPH CORE
// ═══════════════════════════════════════════════════════════════════════════
function attachTime(rows) {
rows.forEach(r => { r.hoursAgo = parseHoursAgo(r.timeLabel); });
return rows;
}
function bestIndices(rows, lowerBetter) {
const sorted = [...rows].sort((a, b) => b.hoursAgo - a.hoursAgo);
const set = new Set();
let best = lowerBetter ? Infinity : -Infinity;
sorted.forEach(r => {
if (lowerBetter ? r.score < best : r.score > best) {
best = r.score; set.add(r.index);
}
});
return set;
}
function buildAxes(rows, canvas, P) {
const scores = rows.map(r => r.score);
const rawMin = Math.min(...scores), rawMax = Math.max(...scores);
const step = niceStep(rawMax - rawMin || 1);
const axMin = Math.floor(rawMin / step) * step;
const axMax = Math.ceil(rawMax / step) * step;
const range = axMax - axMin || step;
const times = rows.map(r => r.hoursAgo);
const tMax = Math.max(...times), tMin = Math.min(...times);
const tRange = tMax - tMin || 1;
const W = canvas.width - P.l - P.r;
const H = canvas.height - P.t - P.b;
return {
mapX: h => P.l + ((tMax - h) / tRange) * W,
mapY: s => canvas.height - P.b - ((s - axMin) / range) * H,
axMin, axMax, step, tMin, tMax,
};
}
function draw(canvas, wrap, rows, lowerBetter, activeIndex) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const P = { t: 40, r: 24, b: 48, l: 72 };
const { mapX, mapY, axMin, axMax, step } = buildAxes(rows, canvas, P);
const C = {
axis: cssVar(wrap, '--ks-axis'),
grid: cssVar(wrap, '--ks-grid'),
bestLine: cssVar(wrap, '--ks-best-line'),
dot: cssVar(wrap, '--ks-dot'),
bestDot: cssVar(wrap, '--ks-best-dot'),
activeRing: cssVar(wrap, '--ks-active-ring'),
};
const bSet = bestIndices(rows, lowerBetter);
ctx.font = '10px monospace';
for (let s = axMin; s <= axMax + 1e-9; s = Math.round((s + step) * 1e8) / 1e8) {
const y = mapY(s);
ctx.beginPath(); ctx.strokeStyle = C.grid; ctx.lineWidth = 1;
ctx.moveTo(P.l, y); ctx.lineTo(canvas.width - P.r, y); ctx.stroke();
ctx.fillStyle = C.axis; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(Number(s.toPrecision(5)), P.l - 8, y);
}
ctx.beginPath(); ctx.strokeStyle = C.axis; ctx.lineWidth = 2;
ctx.moveTo(P.l, P.t); ctx.lineTo(P.l, canvas.height - P.b);
ctx.moveTo(P.l, canvas.height - P.b); ctx.lineTo(canvas.width - P.r, canvas.height - P.b);
ctx.stroke();
const sorted = [...rows].sort((a, b) => b.hoursAgo - a.hoursAgo);
const MIN_DISTANCE = 50; // ラベル同士の最小距離(px)
let lastX = -Infinity;
ctx.fillStyle = C.axis;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
sorted.forEach((r) => {
const currentX = mapX(r.hoursAgo);
// 前に描画したラベルから MIN_DISTANCE 以上離れている場合、または最後のデータの場合に描画
if (Math.abs(currentX - lastX) > MIN_DISTANCE) {
ctx.fillText(fmtTime(r.hoursAgo), currentX, canvas.height - P.b + 8);
lastX = currentX;
}
});
// --- ベストスコアの軌跡(階段状の線)の描画 ---
const bestRows = sorted.filter(r => bSet.has(r.index));
if (bestRows.length >= 1) {
ctx.beginPath();
ctx.strokeStyle = C.bestLine;
ctx.lineWidth = 2;
bestRows.forEach((r, j) => {
const x = mapX(r.hoursAgo);
const y = mapY(r.score);
if (j === 0) {
ctx.moveTo(x, y);
} else {
// 階段状に線を引く(直前のスコアを維持して新しい時間の位置まで横に引き、そこから縦に引く)
ctx.lineTo(x, mapY(bestRows[j - 1].score));
ctx.lineTo(x, y);
}
});
ctx.stroke();
}
// --- 全データポイント(ドット)の描画 ---
rows.forEach(r => {
const x = mapX(r.hoursAgo);
const y = mapY(r.score);
const active = r.index === activeIndex;
ctx.beginPath();
ctx.arc(x, y, active ? 6 : 3.5, 0, Math.PI * 2);
ctx.fillStyle = bSet.has(r.index) ? C.bestDot : C.dot;
ctx.fill();
if (active) {
ctx.strokeStyle = C.activeRing;
ctx.lineWidth = 2;
ctx.stroke();
}
});
return { mapX, mapY };
}
// ═══════════════════════════════════════════════════════════════════════════
// TOOLTIP
// ═══════════════════════════════════════════════════════════════════════════
function showTooltip(tip, wrap, row, page, cx, cy) {
const accent = cssVar(wrap, '--ks-best-line');
tip.style.background = cssVar(wrap, '--ks-tip-bg');
tip.style.borderColor = cssVar(wrap, '--ks-tip-border');
tip.style.color = cssVar(wrap, '--ks-tip-text');
tip.innerHTML = page.tooltipLine1(row).replace('VAR_ACCENT', accent) + `<br>
Score: <b>${row.score}</b><br>
<span style="color:#6c757d;font-size:10px">${row.label}</span><br>
<span style="color:#6c757d;font-size:10px">${row.timeLabel} (${fmtTime(row.hoursAgo)} ago)</span>`;
tip.style.display = 'block';
tip.style.left = `${cx + 16}px`;
tip.style.top = `${cy - 12}px`;
}
// ═══════════════════════════════════════════════════════════════════════════
// INJECT
// ═══════════════════════════════════════════════════════════════════════════
function inject(page) {
LOG(`inject(${page.wrapId}) start`);
if (document.getElementById(page.wrapId)) {
LOG(`inject(${page.wrapId}): already exists, skip`);
return true;
}
const parent = document.querySelector(page.sel.insertParent);
if (!parent) {
WARN(`inject(${page.wrapId}): insertParent "${page.sel.insertParent}" not found`);
return false;
}
LOG(`inject(${page.wrapId}): insertParent found`, parent);
const rawRows = page.scrape(page.sel);
if (rawRows.length < 2) {
WARN(`inject(${page.wrapId}): only ${rawRows.length} rows scraped, need ≥2`);
return false;
}
const allRows = attachTime(rawRows);
LOG(`inject(${page.wrapId}): ${allRows.length} rows with time attached`);
let lowerBetter = true, activeIndex = null, timeWindow = 'all';
const wrap = document.createElement('div');
wrap.id = page.wrapId;
Object.entries(CSS_VARS).forEach(([k, v]) => wrap.style.setProperty(k, v));
Object.assign(wrap.style, {
margin: '16px 0', padding: '14px 18px',
background: 'var(--ks-bg)', fontFamily: 'monospace',
position: 'relative', borderRadius: '6px', boxSizing: 'border-box',
});
const controls = document.createElement('div');
Object.assign(controls.style, {
display: 'flex', alignItems: 'center', gap: '8px',
marginBottom: '10px', flexWrap: 'wrap',
});
const btnStyle = {
fontSize: '11px', background: 'var(--ks-btn-bg)',
border: '1px solid var(--ks-btn-border)', borderRadius: '4px',
padding: '4px 10px', cursor: 'pointer', color: 'var(--ks-axis)',
};
const toggleBtn = document.createElement('button');
toggleBtn.textContent = 'Lower is Better';
Object.assign(toggleBtn.style, btnStyle);
const select = document.createElement('select');
Object.assign(select.style, btnStyle);
[['1d','1 Day'],['1w','1 Week'],['1mo','1 Month'],['all','All Time']].forEach(([v, l]) => {
const o = document.createElement('option');
o.value = v; o.textContent = l;
if (v === timeWindow) o.selected = true;
select.appendChild(o);
});
controls.append(toggleBtn, select);
wrap.appendChild(controls);
const canvas = document.createElement('canvas');
canvas.width = 960; canvas.height = 300;
Object.assign(canvas.style, { width: '100%', height: '300px', display: 'block' });
wrap.appendChild(canvas);
const tip = document.createElement('div');
Object.assign(tip.style, {
position: 'fixed', border: '1px solid', borderRadius: '5px',
padding: '8px 10px', display: 'none', fontSize: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,.14)', zIndex: '9999',
pointerEvents: 'none', lineHeight: '1.7',
});
document.body.appendChild(tip);
const kids = Array.from(parent.children);
// タイトルの次に挿入
const before = kids[1] ?? null;
LOG(`inject(${page.wrapId}): inserting wrap. parent.children=${kids.length}, insertBefore index=${kids.indexOf(before)}`);
parent.insertBefore(wrap, before);
LOG(`inject(${page.wrapId}): wrap inserted ✓`);
function filtered() {
const max = TIME_WINDOWS[timeWindow];
return allRows.filter(r => r.hoursAgo <= max);
}
function render() {
const rows = filtered();
LOG(`render(${page.wrapId}): timeWindow=${timeWindow} rows=${rows.length}`);
if (rows.length < 1) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = cssVar(wrap, '--ks-axis');
ctx.font = '14px monospace'; ctx.textAlign = 'center';
ctx.fillText('No data in this time window.', canvas.width / 2, canvas.height / 2);
tip.style.display = 'none';
return;
}
const { mapX, mapY } = draw(canvas, wrap, rows, lowerBetter, activeIndex);
if (activeIndex === null || !rows.find(r => r.index === activeIndex)) {
const bSet = bestIndices(rows, lowerBetter);
const sorted = [...rows].sort((a, b) => b.hoursAgo - a.hoursAgo);
const best = sorted.filter(r => bSet.has(r.index)).pop();
if (best) {
activeIndex = best.index;
draw(canvas, wrap, rows, lowerBetter, activeIndex);
const rect = canvas.getBoundingClientRect();
showTooltip(tip, wrap, best, page,
rect.left + mapX(best.hoursAgo) * (rect.width / canvas.width),
rect.top + mapY(best.score) * (rect.height / canvas.height));
LOG(`render: default highlight → index=${activeIndex} score=${best.score}`);
}
}
canvas.onmousemove = (e) => {
const rows2 = filtered();
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
const P = { t: 40, r: 24, b: 48, l: 72 };
const { mapX: hx, mapY: hy } = buildAxes(rows2, canvas, P);
let dist = 14, found = null;
rows2.forEach(r => {
const d = Math.hypot(mx - hx(r.hoursAgo), my - hy(r.score));
if (d < dist) { dist = d; found = r; }
});
const ni = found ? found.index : null;
if (ni !== activeIndex) {
activeIndex = ni;
draw(canvas, wrap, rows2, lowerBetter, activeIndex);
}
if (found) showTooltip(tip, wrap, found, page, e.clientX, e.clientY);
else tip.style.display = 'none';
};
canvas.onmouseleave = () => { tip.style.display = 'none'; };
}
toggleBtn.onclick = () => {
lowerBetter = !lowerBetter;
toggleBtn.textContent = lowerBetter ? 'Lower is Better' : 'Higher is Better';
activeIndex = null; render();
};
select.onchange = () => { timeWindow = select.value; activeIndex = null; render(); };
render();
LOG(`inject(${page.wrapId}): done ✓`);
return true;
}
// ═══════════════════════════════════════════════════════════════════════════
// SPA CONTROLLER
// ═══════════════════════════════════════════════════════════════════════════
let currentUrl = location.href;
let domObserver = null;
let injectTimer = null;
function clearWatchers() {
if (domObserver) { domObserver.disconnect(); domObserver = null; LOG('MutationObserver cleared'); }
if (injectTimer) { clearInterval(injectTimer); injectTimer = null; LOG('retry timer cleared'); }
}
function activePage() {
const path = location.pathname;
const match = Object.entries(PAGES).find(([, p]) => p.urlPattern.test(path));
LOG(`activePage(): pathname="${path}" → ${match ? match[0] : 'none'}`);
return match ? match[1] : null;
}
/**
* Stage 1: wait for `selector` to exist in DOM.
* Uses MutationObserver + polling fallback.
*/
function waitForElement(selector, cb, timeoutMs = 20000) {
clearWatchers();
if (document.querySelector(selector)) {
LOG(`waitForElement: "${selector}" already present`);
cb(); return;
}
LOG(`waitForElement: watching for "${selector}" (timeout=${timeoutMs}ms)`);
domObserver = new MutationObserver(() => {
if (document.querySelector(selector)) {
LOG(`waitForElement: "${selector}" appeared via MutationObserver`);
clearWatchers();
cb();
}
});
domObserver.observe(document.body, { childList: true, subtree: true });
const deadline = setTimeout(() => {
WARN(`waitForElement: timeout waiting for "${selector}"`);
clearWatchers();
}, timeoutMs);
let retries = 0;
injectTimer = setInterval(() => {
if (document.querySelector(selector)) {
LOG(`waitForElement: "${selector}" appeared via polling (attempt ${retries})`);
clearTimeout(deadline);
clearWatchers();
cb();
} else if (++retries >= timeoutMs / 500) {
WARN(`waitForElement: polling timeout for "${selector}"`);
clearTimeout(deadline);
clearWatchers();
}
}, 500);
}
/**
* Stage 2: コンテナは出現済みだがデータ行がまだない場合、
* データセレクタが1件以上現れるまで MutationObserver で待つ。
* タイムアウト後は諦める。
*/
function waitForData(page, cb, timeoutMs = 15000) {
// 各ページが「データあり」と判断できる最低限のセレクタ
const dataSelectors = {
leaderboard: page.sel.name, // '.kbQyFD'
submissions: page.sel.name, // '.hwJapD'
};
// page オブジェクトから対応するキーを逆引き
const pageName = Object.keys(PAGES).find(k => PAGES[k] === page) || '?';
const dataSel = dataSelectors[pageName] || page.sel.name;
if (document.querySelector(dataSel)) {
cb(); return;
}
let obs = null;
const deadline = setTimeout(() => {
WARN(`waitForData(${pageName}): timeout — data rows never appeared`);
if (obs) { obs.disconnect(); obs = null; }
}, timeoutMs);
obs = new MutationObserver(() => {
if (document.querySelector(dataSel)) {
clearTimeout(deadline);
obs.disconnect(); obs = null;
// React が同一フレームでまだ追加中の可能性があるので少し待つ
setTimeout(cb, 300);
}
});
obs.observe(document.body, { childList: true, subtree: true });
}
function handleNavigation() {
const page = activePage();
if (!page) {
return;
}
if (document.getElementById(page.wrapId)) {
return;
}
// Stage 1: コンテナ出現待ち
waitForElement(page.sel.insertParent, () => {
// Stage 2: データ行出現待ち
waitForData(page, () => {
requestAnimationFrame(() => inject(page));
});
});
}
function patchHistory(method) {
const orig = history[method];
history[method] = function (...args) {
const before = location.href;
orig.apply(this, args);
const after = location.href;
if (after !== currentUrl) {
currentUrl = after;
handleNavigation();
}
};
}
patchHistory('pushState');
patchHistory('replaceState');
window.addEventListener('popstate', (e) => {
handleNavigation();
});
window.addEventListener('hashchange', (e) => {
handleNavigation();
});
handleNavigation();
})();