Kaggle Score Chart

Score graph for Kaggle submissions & leaderboard

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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

})();