Kaggle Score Chart

Score graph for Kaggle submissions & leaderboard

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();

})();