您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在 Codeforces 个人页自动内嵌练习可视化面板(零依赖:纯原生 SVG 渲染;Shadow DOM 隔离;无需点击)
// ==UserScript== // @name CF Practice Visualizer - Pure SVG, Auto, Shadow Scoped // @namespace cfviz-auto-svg // @version 1.0.0 // @description 在 Codeforces 个人页自动内嵌练习可视化面板(零依赖:纯原生 SVG 渲染;Shadow DOM 隔离;无需点击) // @match https://codeforces.com/profile/* // @match https://*.codeforces.com/profile/* // @run-at document-idle // @grant none // @license MIT // @author paqi // ==/UserScript== (() => { 'use strict'; /*** -------------------- 基础工具 -------------------- ***/ const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const qs = (sel, root = document) => root.querySelector(sel); const onceId = 'cfviz-host'; function detectHandle () { // 1) URL const m1 = location.pathname.match(/\/profile\/([^/?#]+)/); if (m1?.[1]) return decodeURIComponent(m1[1]); // 2) DOM 标注 const a = qs('.main-info a.rated-user, a.rated-user'); if (a?.textContent) return a.textContent.trim(); // 3) OG const og = qs('meta[property="og:url"]')?.content; const m2 = og && og.match(/\/profile\/([^/?#]+)/); if (m2?.[1]) return decodeURIComponent(m2[1]); return null; } async function cfFetch (url, params = {}, retries = 8) { const q = new URLSearchParams(params).toString(); for (let i = 0; i < retries; i++) { try { const res = await fetch(`${url}?${q}`, { credentials: 'same-origin' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); if (json.status === 'OK') return json.result; throw new Error(json.comment || 'CF API failed'); } catch (e) { if (i === retries - 1) throw e; await sleep(250 * Math.pow(1.6, i)); } } } /*** -------------------- 插入容器(Shadow DOM) -------------------- ***/ function ensureHost () { if (document.getElementById(onceId)) return document.getElementById(onceId); const anchor = qs('#ratingChart') || qs('#pageContent .userActivityRoundBox') || qs('#pageContent') || document.body; const host = document.createElement('div'); host.id = onceId; host.className = 'roundbox userActivityRoundBox borderTopRound borderBottomRound'; host.style.marginTop = '1em'; (anchor.parentElement || anchor).insertAdjacentElement('afterend', host); return host; } function mountShadow (host) { const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { display:block; } #cfviz-wrap { padding: 16px; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans SC", sans-serif; } #cfviz-title { font-size: 18px; font-weight: 800; margin: 0 0 8px; } #cfviz-sub { color:#64748b; margin: 0 0 12px; } #cfviz-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } @media (max-width: 960px) { #cfviz-grid { grid-template-columns: 1fr; } } .cfviz-card { background: rgba(255,255,255,.9); border-radius: 12px; padding: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.06); } .cfviz-card h3 { margin: 0 0 8px; font-size: 14px; } .cfviz-svg { width: 100%; height: 260px; display: block; } /* 热格 */ #cfviz-heat { display: grid; grid-template-columns: repeat(6,1fr); gap: 6px; } .cfviz-cell { padding: 14px 4px; border-radius: 10px; text-align: center; font-weight: 800; font-size: 12px; line-height: 1.2; user-select: none; } /* 9 桶 */ #cfviz-buckets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .cfviz-bcell { padding: 18px 10px; border-radius: 12px; color: #fff; text-align: center; font-weight: 900; font-size: 18px; } .cfviz-bcell small { display:block; margin-top:6px; font-size: 11px; opacity:.9; } /* 微提示 */ #cfviz-tip { color:#6b7280; margin-top:6px; } </style> <div id="cfviz-wrap"> <div id="cfviz-title">练习可视化面板</div> <div id="cfviz-sub"></div> <div id="cfviz-grid"> <div class="cfviz-card"> <h3>预测 vs 实际 Rating</h3> <svg id="cfviz-line" class="cfviz-svg"></svg> </div> <div class="cfviz-card"> <h3>30 天 ΔRating 热力图</h3> <div id="cfviz-heat"></div> </div> <div class="cfviz-card"> <h3>30 天每日刷题分段(堆叠)</h3> <svg id="cfviz-bar" class="cfviz-svg"></svg> </div> <div class="cfviz-card"> <h3>历史刷题颜色图</h3> <div id="cfviz-buckets"></div> </div> </div> <div id="cfviz-tip"></div> </div> `; return { shadow, $: (s) => shadow.querySelector(s) }; } /*** -------------------- 数据处理(同你原模型) -------------------- ***/ const K_FIXED = 255, L_FIXED = 600, ALPHA = 0.4625; const DIFFS = [ ["3000+", r=>r>=3000, "#800000"], ["2400-2999", r=>r<3000 && r>=2400, "#ff0000"], ["2100-2399", r=>r>=2100, "#ff8c00"], ["1900-2099", r=>r>=1900, "#b000b0"], ["1600-1899", r=>r>=1600, "#3250ff"], ["1400-1599", r=>r>=1400, "#00b4c8"], ["1200-1399", r=>r>=1200, "#00c000"], ["800-1199", r=>r>=800, "#808080"], ["unrated", r=>!r, "#000"] ]; const BUCKET9 = [ ["(无评级)", r=>r==null, "#191919"], ["(0-1199)", r=>r!=null && r<1200, "#808080"], ["(1200-1399)", r=>r>=1200 && r<=1399, "#00c000"], ["(1400-1599)", r=>r>=1400 && r<=1599, "#00b4c8"], ["(1600-1899)", r=>r>=1600 && r<=1899, "#3250ff"], ["(1900-2099)", r=>r>=1900 && r<=2099, "#b000b0"], ["(2100-2399)", r=>r>=2100 && r<=2399, "#ff8c00"], ["(2400-2999)", r=>r>=2400 && r<=2999, "#ff0000"], ["(3000+)", r=>r>=3000, "#800000"] ]; const ts2d = ts => new Date(ts * 1000); const dKey = d => { const x = new Date(d.getTime() - d.getTimezoneOffset()*60000); return x.toISOString().slice(0,10); }; function simulate (tasks, k=0.65, p=3.5) { const N = tasks.length; let R = 800; const series = []; let i = 0; for (const t of tasks) { i++; const phi = 1 + k * Math.pow(i/N, p); const mapped = 800 + (t.rating - 800) * phi; const base_w = Math.pow(i, ALPHA) - Math.pow(i-1, ALPHA); const delta = mapped - R; const scale = 1 / (1 + Math.exp(-delta / L_FIXED)); const expected = 1 / (1 + Math.pow(10, delta/400)); R += K_FIXED * (1 - expected) * base_w * scale; series.push({ ts: t.ts, R }); } return series; } function dedupEarliestAC (subs) { const earliest = new Map(); for (const s of subs) { if (s.verdict !== 'OK') continue; const cid = s.contestId || s.problem?.contestId; const pid = `${cid}-${s.problem.index}`; if (!earliest.has(pid) || s.creationTimeSeconds < earliest.get(pid).creationTimeSeconds) { earliest.set(pid, s); } } return [...earliest.values()]; } function dailyDifficulty (subs) { const map = new Map(); for (const s of subs) { if (s.verdict !== 'OK') continue; const rating = s.problem?.rating ?? null; const k = dKey(ts2d(s.creationTimeSeconds)); if (!map.has(k)) { const obj = {}; DIFFS.forEach(([name]) => obj[name] = 0); map.set(k, obj); } for (const [name, cond] of DIFFS) { if (cond(rating)) { map.get(k)[name]++; break; } } } return map; } /*** -------------------- 纯 SVG 渲染 -------------------- ***/ function clearSVG(svg) { while (svg.firstChild) svg.removeChild(svg.firstChild); } function lineChart (svg, dates, pred, actual) { // 尺寸与内边距 const W = svg.clientWidth || 600, H = svg.clientHeight || 260; svg.setAttribute('viewBox', `0 0 ${W} ${H}`); clearSVG(svg); const m = { l:40, r:10, t:14, b:26 }; // 若没有数据,给个提示 if (!dates.length || (!pred.length && !actual.length)) { const t = document.createElementNS('http://www.w3.org/2000/svg','text'); t.setAttribute('x', 10); t.setAttribute('y', 22); t.setAttribute('fill', '#6b7280'); t.textContent = '无数据'; svg.appendChild(t); return; } const minT = +dates[0], maxT = +dates[dates.length-1] || (+dates[0]+1); const allY = [...pred, ...actual].filter(v=>v!=null&&isFinite(v)); const minY = Math.min(...allY) - 20, maxY = Math.max(...allY) + 20; const x = t => m.l + ( (t - minT) / Math.max(1, maxT - minT) ) * (W - m.l - m.r); const y = v => m.t + ( (maxY - v) / Math.max(1, maxY - minY) ) * (H - m.t - m.b); // 网格线 const grid = document.createElementNS('http://www.w3.org/2000/svg','g'); const yTicks = 5, xTicks = Math.min(6, dates.length); for (let i=0;i<=yTicks;i++){ const yy = m.t + i*(H-m.t-m.b)/yTicks; const ln = document.createElementNS('http://www.w3.org/2000/svg','line'); ln.setAttribute('x1', m.l); ln.setAttribute('x2', W-m.r); ln.setAttribute('y1', yy); ln.setAttribute('y2', yy); ln.setAttribute('stroke', '#e5e7eb'); ln.setAttribute('stroke-width','1'); grid.appendChild(ln); } for (let i=0;i<=xTicks;i++){ const tt = minT + i*(maxT-minT)/xTicks; const xx = x(tt); const ln = document.createElementNS('http://www.w3.org/2000/svg','line'); ln.setAttribute('y1', m.t); ln.setAttribute('y2', H-m.b); ln.setAttribute('x1', xx); ln.setAttribute('x2', xx); ln.setAttribute('stroke', '#f1f5f9'); ln.setAttribute('stroke-width','1'); grid.appendChild(ln); } svg.appendChild(grid); // 折线 const makePath = (arr, col) => { if (!arr.length) return; const path = document.createElementNS('http://www.w3.org/2000/svg','path'); let d = `M ${x(+dates[0])} ${y(arr[0])}`; for (let i=1;i<dates.length;i++) { if (arr[i]==null) continue; d += ` L ${x(+dates[i])} ${y(arr[i])}`; } path.setAttribute('d', d); path.setAttribute('fill','none'); path.setAttribute('stroke', col); path.setAttribute('stroke-width','2'); svg.appendChild(path); }; makePath(pred, '#ec4899'); // 粉 makePath(actual, '#6366f1'); // 靛 // y 轴刻度 for (let i=0;i<=yTicks;i++){ const val = Math.round(minY + i*(maxY - minY)/yTicks); const ty = m.t + (yTicks-i)*(H-m.t-m.b)/yTicks + 4; const t = document.createElementNS('http://www.w3.org/2000/svg','text'); t.setAttribute('x', 4); t.setAttribute('y', ty); t.setAttribute('fill', '#94a3b8'); t.setAttribute('font-size','11'); t.textContent = val; svg.appendChild(t); } // x 轴刻度(显示 MM-DD) for (let i=0;i<=xTicks;i++){ const tt = new Date(minT + i*(maxT-minT)/xTicks); const xx = x(+tt); const t = document.createElementNS('http://www.w3.org/2000/svg','text'); t.setAttribute('x', xx); t.setAttribute('y', H-6); t.setAttribute('text-anchor','middle'); t.setAttribute('fill', '#94a3b8'); t.setAttribute('font-size','11'); t.textContent = `${(tt.getMonth()+1).toString().padStart(2,'0')}-${tt.getDate().toString().padStart(2,'0')}`; svg.appendChild(t); } } function stackedBar (svg, labels, datasets) { const W = svg.clientWidth || 600, H = svg.clientHeight || 260; svg.setAttribute('viewBox', `0 0 ${W} ${H}`); clearSVG(svg); const m = { l:40, r:10, t:10, b:28 }; const totals = labels.map((_,i)=>datasets.reduce((s,ds)=>s+(ds.data[i]||0),0)); const maxY = Math.max(1, ...totals); const bw = (W - m.l - m.r) / labels.length; const y = v => m.t + (1 - v/maxY)*(H - m.t - m.b); // y 轴网格 const grid = document.createElementNS('http://www.w3.org/2000/svg','g'); for (let i=0;i<=4;i++){ const yy = m.t + i*(H-m.t-m.b)/4; const ln = document.createElementNS('http://www.w3.org/2000/svg','line'); ln.setAttribute('x1', m.l); ln.setAttribute('x2', W-m.r); ln.setAttribute('y1', yy); ln.setAttribute('y2', yy); ln.setAttribute('stroke', '#e5e7eb'); ln.setAttribute('stroke-width','1'); grid.appendChild(ln); } svg.appendChild(grid); for (let i=0;i<labels.length;i++){ let cum = 0; for (const ds of datasets){ const v = ds.data[i] || 0; if (v === 0) continue; const x0 = m.l + i*bw + 1; const x1 = x0 + Math.max(1, bw - 2); const y1 = y(cum); const y2 = y(cum + v); const rect = document.createElementNS('http://www.w3.org/2000/svg','rect'); rect.setAttribute('x', x0); rect.setAttribute('y', y2); rect.setAttribute('width', x1 - x0); rect.setAttribute('height', Math.max(1, y1 - y2)); rect.setAttribute('fill', ds.color || '#999'); rect.setAttribute('opacity', '0.95'); svg.appendChild(rect); cum += v; } } // 轴刻度 for (let i=0;i<labels.length;i+=Math.ceil(labels.length/6)){ const t = document.createElementNS('http://www.w3.org/2000/svg','text'); const x0 = m.l + (i+0.5)*bw; t.setAttribute('x', x0); t.setAttribute('y', H-6); t.setAttribute('text-anchor','middle'); t.setAttribute('fill','#94a3b8'); t.setAttribute('font-size','11'); t.textContent = labels[i].slice(5); svg.appendChild(t); } for (let i=0;i<=4;i++){ const v = Math.round(i*maxY/4); const ty = m.t + (4-i)*(H-m.t-m.b)/4 + 4; const t = document.createElementNS('http://www.w3.org/2000/svg','text'); t.setAttribute('x', 4); t.setAttribute('y', ty); t.setAttribute('fill','#94a3b8'); t.setAttribute('font-size','11'); t.textContent = v; svg.appendChild(t); } } /*** -------------------- 热格和桶渲染(纯 DOM) -------------------- ***/ function renderHeat (gridEl, series) { const dailyD = {}, dailyEnd = {}; const toMid = ts => { const d = new Date(ts*1000); d.setHours(0,0,0,0); return d; }; const kOf = d => d.toISOString().slice(0,10); let prev = 800; for (const p of series){ const k = kOf(toMid(p.ts)); dailyD[k] = (dailyD[k]||0) + (p.R - prev); dailyEnd[k] = p.R; prev = p.R; } const today = new Date(); today.setHours(0,0,0,0); const cells = []; for (let i=29;i>=0;i--){ const d = new Date(today); d.setDate(today.getDate()-i); const k = kOf(d); cells.push({ delta: dailyD[k]||0, endR: dailyEnd[k] ?? null, date: k }); } const vmax = Math.max(...cells.map(c=>Math.abs(c.delta))) || 1; const bg = v => { const t = Math.min(1, Math.abs(v)/vmax), a = 0.25 + t*0.65; return v>=0 ? `rgba(59,130,246,${a})` : `rgba(244,63,94,${a})`; }; gridEl.innerHTML = ''; for (const {delta, endR, date} of cells) { const div = document.createElement('div'); div.className = 'cfviz-cell'; div.style.background = bg(delta); if (Math.abs(delta) > 0.6*vmax) div.style.color = '#fff'; const deltaTxt = Math.abs(delta)>=0.01 ? ((delta>=0?'+':'') + delta.toFixed(1)) : ''; div.innerHTML = `${deltaTxt}<br>${endR!=null?Math.round(endR):''}`; div.title = `${date}\nΔRating: ${delta.toFixed(1)}${endR!=null?`\nEnd-R: ${Math.round(endR)}`:''}`; gridEl.appendChild(div); } } function renderBuckets (container, subs) { const counts = Array(9).fill(0); for (const s of subs){ const rating = s.problem?.rating ?? null; for (let i=0;i<BUCKET9.length;i++){ if (BUCKET9[i][1](rating)){ counts[i]++; break; } } } container.innerHTML = ''; BUCKET9.forEach(([lbl,,color],i)=>{ const d = document.createElement('div'); d.className = 'cfviz-bcell'; d.style.background = color; d.innerHTML = `${counts[i]}<small>${lbl}</small>`; container.appendChild(d); }); } /*** -------------------- 主流程 -------------------- ***/ async function renderAll () { if (document.getElementById(onceId)) return; // 防重复 const host = ensureHost(); const { $, shadow } = mountShadow(host); const sub = $('#cfviz-sub'); const tip = $('#cfviz-tip'); const handle = detectHandle(); if (!handle) { sub.textContent = '未识别到用户名'; return; } sub.textContent = `已加载:@${handle}`; try { const [st, rt, info] = await Promise.all([ cfFetch('https://codeforces.com/api/user.status', { handle, count: 15000 }), cfFetch('https://codeforces.com/api/user.rating', { handle }), cfFetch('https://codeforces.com/api/user.info', { handles: handle }) ]); const subs = st; const contests = rt; const user = info?.[0]; // 构造任务(首次 AC) const earliest = new Map(); for (const subm of subs.slice().reverse()){ if (subm.verdict !== 'OK') continue; const cid = subm.contestId || subm.problem?.contestId; const pid = `${cid}-${subm.problem.index}`; if (!earliest.has(pid)){ const rating = (typeof subm.problem?.rating === 'number') ? subm.problem.rating : 1500; earliest.set(pid, { ts: subm.creationTimeSeconds, rating }); } } const tasks = [...earliest.values()].sort((a,b)=>a.ts-b.ts); const series = simulate(tasks); // 映射预测到比赛时间点 const dates = contests.map(c => ts2d(c.ratingUpdateTimeSeconds)); const actual = contests.map(c => c.newRating); const predAt = []; let idx = 0; for (const c of contests){ while (idx+1 < series.length && series[idx].ts < c.ratingUpdateTimeSeconds) idx++; predAt.push( (series[Math.min(idx, series.length-1)]?.R) ?? 800 ); } // 折线 const lineSVG = $('#cfviz-line'); const ro1 = new ResizeObserver(()=> lineChart(lineSVG, dates, predAt, actual)); ro1.observe(lineSVG); lineChart(lineSVG, dates, predAt, actual); // 热格 renderHeat($('#cfviz-heat'), series); // 30 天分段 const today = new Date(); today.setHours(0,0,0,0); const keys = []; for (let i=29;i>=0;i--){ const d = new Date(today); d.setDate(today.getDate()-i); keys.push(dKey(d)); } const uniq = dedupEarliestAC(subs); const map = dailyDifficulty(uniq); const dsets = DIFFS.map(([name,,color]) => ({ label:name, data: keys.map(k=>map.get(k)?.[name]||0), color })); const barSVG = $('#cfviz-bar'); const ro2 = new ResizeObserver(()=> stackedBar(barSVG, keys, dsets)); ro2.observe(barSVG); stackedBar(barSVG, keys, dsets); // 9 桶 renderBuckets($('#cfviz-buckets'), uniq); tip.textContent = user?.handle ? `完成渲染:@${user.handle}` : '完成渲染'; } catch (e) { console.error('[cfviz] load failed:', e); tip.textContent = '加载失败:' + (e?.message || e); } } // 首次与前进/后退 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(renderAll, 0)); } else { setTimeout(renderAll, 0); } window.addEventListener('popstate', () => { const old = document.getElementById(onceId); if (old) old.remove(); renderAll(); }); })();