CF Practice Visualizer - Pure SVG, Auto, Shadow Scoped

在 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();
  });
})();