NOTICE: By continued use of this site you understand and agree to the binding Servicevoorwaarden and Privacybeleid.
// ==UserScript==
// @name CF解题数据可视化 (Pro Max)
// @name:en codeforces analytics
// @namespace https://codeforces.com/profile/tongwentao
// @version 2.3.0
// @description 全方位分析Codeforces做题数据:支持中英文切换,新增一键生成高清长图分享功能。
// @description:en Analyse Codeforces profiles with 8 dimensions, bilingual support, and high-res image export.
// @author tongwentao
// @match https://codeforces.com/profile/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @connect greasyfork.org
// @require https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js
// @require https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// --- 国际化 (i18n) 配置 ---
let currentLang = 'en';
const i18n = {
en: {
loading: 'Loading Analytics (Pro Max)...',
fetchError: 'Failed to load data, possibly rate-limited by CF. Please try again later.',
titleText: 'Codeforces Analytics',
btnSwitch: '中文',
btnShare: 'Share (Export Image)',
generating: 'Generating...',
ratings: 'Problem Ratings',
tags: 'Tags Solved',
lang: 'Programming Language',
verdict: 'Verdict Distribution',
attempts: 'Average Attempts to AC',
participant: 'Participant Type',
performance: 'Execution Performance',
memoryPerf: 'Memory Usage (KB vs Rating)',
timeline: 'Activity Timeline (Monthly)',
heatmap: 'Submission Heatmap',
unsolved: 'Unsolved Problems (Total: {n})',
submissions: 'Submissions',
time: 'Time (ms)',
memory: 'Memory (KB)',
rating: 'Rating',
points: 'Points Earned',
streak: 'Max Streak',
days: 'days',
speed: 'Contest Speed Analysis',
try1: '1 Try (One Shot)',
try2: '2 Tries',
try3_5: '3-5 Tries',
tryMore: '> 5 Tries (Struggle)',
genErrorConsole: 'Failed to generate image:',
genErrorAlert: 'Sorry, failed to generate the image. Please check the console for errors.'
},
zh: {
loading: '正在加载分析数据 (Pro Max)...',
fetchError: '加载数据失败,可能是 CF 接口限流,请稍后再试。',
titleText: 'CF 解题数据可视化',
btnSwitch: 'English',
btnShare: '分享 (生成长图)',
generating: '正在生成...',
ratings: '题目难度分布',
tags: '题目标签分布',
lang: '编程语言偏好',
verdict: '提交结果分布',
attempts: '平均 AC 尝试次数',
participant: '参赛类型分布',
performance: '执行性能分布 (时间 vs 难度)',
memoryPerf: '内存使用分布 (KB vs 难度)',
timeline: '刷题活跃度 (月度)',
heatmap: '提交热力图',
unsolved: '未解决题目 (总计: {n} 题)',
submissions: '提交数',
time: '运行时间 (ms)',
memory: '内存 (KB)',
rating: '难度',
points: '获得分数',
streak: '最长连续',
days: '天',
speed: '比赛速度分析',
try1: '1 Try (一发入魂)',
try2: '2 Tries',
try3_5: '3-5 Tries',
tryMore: '> 5 Tries (折磨)',
genErrorConsole: '生成图片失败:',
genErrorAlert: '抱歉,生成图片失败,请检查控制台报错。'
}
};
const t = (key, params = {}) => {
let text = i18n[currentLang][key] || key;
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, v);
}
return text;
};
const chartInstances = [];
window.addEventListener('resize', () => {
chartInstances.forEach(chart => chart && chart.resize());
});
const getRatingColor = (rating) => {
if (rating >= 3000) return '#aa0100';
if (rating >= 2600) return '#ff3333';
if (rating >= 2400) return '#ff7777';
if (rating >= 2300) return '#ffbb55';
if (rating >= 2100) return '#ffcc87';
if (rating >= 1900) return '#ff88ff';
if (rating >= 1600) return '#aaaaff';
if (rating >= 1400) return '#76ddbb';
if (rating >= 1200) return '#76ff77';
return '#cccccc';
};
// --- DOM 渲染与容器管理 ---
const initDashboardContainer = (res) => {
let container = document.getElementById('cf-analytics-wrapper');
if (!container) {
container = document.createElement('div');
container.id = 'cf-analytics-wrapper';
container.style.cssText = 'margin-top: 2em; padding: 20px; background: #ffffff; border-radius: 8px; box-sizing: border-box;';
document.getElementById('pageContent').appendChild(container);
}
container.innerHTML = `
<div id="cf-header-bar" style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #ccc; padding-bottom: 10px; margin-bottom: 15px;">
<h2 style="margin: 0; color: #3b5998; font-weight: bold;">${t('titleText')}</h2>
<div style="display: flex; gap: 10px;">
<button id="cf-share-btn" style="padding: 5px 15px; cursor: pointer; border-radius: 4px; border: 1px solid #28a745; background: #28a745; color: white; font-weight: bold; transition: background 0.2s;">
${t('btnShare')}
</button>
<button id="cf-lang-toggle" style="padding: 5px 15px; cursor: pointer; border-radius: 4px; border: 1px solid #0073e6; background: #0073e6; color: white; font-weight: bold; transition: background 0.2s;">
${t('btnSwitch')}
</button>
</div>
</div>
<div id="cf-analytics-dashboard" style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1em 0;"></div>
`;
document.getElementById('cf-lang-toggle').addEventListener('click', () => {
currentLang = currentLang === 'en' ? 'zh' : 'en';
chartInstances.forEach(chart => chart && chart.dispose());
chartInstances.length = 0;
drawCharts(res);
});
// 分享截图逻辑 (带用户名注入与图例数据展示)
document.getElementById('cf-share-btn').addEventListener('click', async () => {
const btn = document.getElementById('cf-share-btn');
const originalText = btn.innerText;
btn.innerText = t('generating');
btn.disabled = true;
btn.style.opacity = '0.7';
try {
const wrapper = document.getElementById('cf-analytics-wrapper');
const handle = window.location.pathname.split('/').pop();
// 隐藏按钮
const headerBtns = wrapper.querySelector('div[style*="gap: 10px"]');
if (headerBtns) headerBtns.style.display = 'none';
// 修改标题加入用户名
const titleEl = wrapper.querySelector('h2');
const originalTitle = titleEl.innerText;
titleEl.innerText = `${originalTitle} @${handle}`;
// 强制宽度
const originalWidth = wrapper.style.width;
const originalMaxWidth = wrapper.style.maxWidth;
wrapper.style.width = '1200px';
wrapper.style.maxWidth = '1200px';
// 为截图修改图表样式:将数值放入图例
chartInstances.forEach(chart => {
if (chart) {
const option = chart.getOption();
if (option.series && option.series[0] && option.series[0].type === 'pie') {
const data = option.series[0].data;
chart.setOption({
legend: {
// 自定义图例文字,格式:名字: 数量
formatter: function (name) {
const item = data.find(d => d.name === name);
return item ? `${name}: ${item.value}` : name;
},
textStyle: {
width: 150, // 稍微拉宽防止数字被省略号截断
overflow: 'truncate'
}
},
series: [{ label: { show: false } }] // 确保外面的蜘蛛网线关闭
});
}
chart.resize();
}
});
await new Promise(r => setTimeout(r, 800));
const canvas = await html2canvas(wrapper, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: 1200
});
// 恢复原状
wrapper.style.width = originalWidth;
wrapper.style.maxWidth = originalMaxWidth;
titleEl.innerText = originalTitle;
if (headerBtns) headerBtns.style.display = 'flex';
chartInstances.forEach(chart => {
if (chart) {
const option = chart.getOption();
if (option.series && option.series[0] && option.series[0].type === 'pie') {
chart.setOption({
legend: {
formatter: '{name}', // 恢复默认图例只显示名字
textStyle: { width: 100, overflow: 'truncate' }
}
});
}
chart.resize();
}
});
// 触发下载
const imgData = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = imgData;
a.download = `CF_Stats_${handle}.png`;
a.click();
} catch (err) {
console.error(t('genErrorConsole'), err);
alert(t('genErrorAlert'));
} finally {
btn.innerText = originalText;
btn.disabled = false;
btn.style.opacity = '1';
}
});
};
const createChartContainer = (id, widthStr = '48%') => {
const isMobile = window.innerWidth < 800;
const finalWidth = isMobile ? '100%' : widthStr;
const div = `<div class="roundbox userActivityRoundBox borderTopRound borderBottomRound" id="${id}" style="width: ${finalWidth}; height:400px; padding:2em 1em 0 1em; box-sizing: border-box;"></div>`;
document.getElementById('cf-analytics-dashboard').insertAdjacentHTML('beforeend', div);
return document.getElementById(id);
};
// --- 图表渲染主逻辑 ---
function drawCharts(res) {
initDashboardContainer(res);
drawTimelineChart('timelineChart', t('timeline'), res.timeline, '100%');
drawBarChart('ratingChart', t('ratings'), res.rating, '100%');
const localizedAttempts = {};
for (const [key, value] of Object.entries(res.attempts)) {
localizedAttempts[t(key)] = value;
}
drawPieChart('tagsChart', t('tags'), res.tags);
drawPieChart('langChart', t('lang'), res.lang);
drawPieChart('verdictChart', t('verdict'), res.verdicts);
drawPieChart('attemptsChart', t('attempts'), localizedAttempts);
drawPieChart('participantChart', t('participant'), res.participantType);
drawScatterChart('performanceChart', t('performance'), res.performance, 'time');
drawScatterChart('memoryChart', t('memoryPerf'), res.memoryPerformance, 'memory');
drawSpeedChart('speedChart', t('speed'), res.speedAnalysis, '49%');
drawStatsSummary(res.stats);
drawUnsolvedChart(res.unsolved);
}
// --- 具体图表绘制函数 ---
function drawBarChart(id, titleText, dataObj, width) {
if (Object.keys(dataObj).length === 0) return;
const chartDom = createChartContainer(id, width);
const myChart = echarts.init(chartDom);
chartInstances.push(myChart);
const xData = Object.keys(dataObj).sort((a, b) => a - b);
const yData = xData.map(key => dataObj[key]);
myChart.setOption({
title: { text: titleText, left: 'center' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: [{ type: 'category', data: xData, axisTick: { alignWithLabel: true } }],
yAxis: [{ type: 'value' }],
series: [{
name: 'Solved', type: 'bar', barWidth: '60%',
data: yData.map((value, index) => ({
value: value, itemStyle: { color: getRatingColor(Number(xData[index])) }
}))
}]
});
}
function drawPieChart(id, titleText, dataObj) {
if (Object.keys(dataObj).length === 0) return;
const chartDom = createChartContainer(id, '49%');
const myChart = echarts.init(chartDom);
chartInstances.push(myChart);
const dataArr = Object.entries(dataObj)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
myChart.setOption({
title: {
text: titleText,
left: 'center',
top: 10,
textStyle: { fontSize: 14 }
},
tooltip: { trigger: 'item', formatter: '{b} : {c} ({d}%)' },
legend: {
type: 'scroll',
orient: 'vertical',
right: '2%',
top: 55,
bottom: 20,
width: '45%',
textStyle: {
fontSize: 11,
width: 100,
overflow: 'truncate'
},
tooltip: { show: true }
},
series: [{
type: 'pie',
radius: ['35%', '55%'],
center: ['33%', '55%'],
itemStyle: { borderRadius: 5, borderColor: '#fff', borderWidth: 2 },
data: dataArr,
label: { show: false }, // 默认不显示,悬浮才高亮
emphasis: { label: { show: true, fontSize: 12, fontWeight: 'bold' } }
}]
});
}
function drawTimelineChart(id, titleText, dataObj, width) {
if (Object.keys(dataObj).length === 0) return;
const chartDom = createChartContainer(id, width);
const myChart = echarts.init(chartDom);
chartInstances.push(myChart);
const xData = Object.keys(dataObj).sort();
const yData = xData.map(key => dataObj[key]);
myChart.setOption({
title: { text: titleText, left: 'center' },
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: xData },
yAxis: { type: 'value', name: t('submissions') },
dataZoom: [{ type: 'inside', start: 0, end: 100 }, { start: 0, end: 100 }],
series: [{
name: t('submissions'), type: 'line', smooth: true,
areaStyle: { opacity: 0.3, color: '#0073e6' },
lineStyle: { color: '#0073e6' }, itemStyle: { color: '#0073e6' },
data: yData
}]
});
}
function drawScatterChart(id, titleText, dataArr, metric) {
if (dataArr.length === 0) return;
const chartDom = createChartContainer(id, '49%');
const myChart = echarts.init(chartDom);
chartInstances.push(myChart);
const isMemory = metric === 'memory';
const axisName = isMemory ? t('memory') : t('time');
const color = isMemory ? 'rgba(40, 167, 69, 0.6)' : 'rgba(0, 115, 230, 0.6)';
myChart.setOption({
title: { text: titleText, left: 'center', textStyle: { fontSize: 14 } },
grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true },
tooltip: {
formatter: function (param) {
const data = param.data;
return `<div style="font-weight:bold;">${data[2]}</div>${axisName}: ${data[0]}<br/>${t('rating')}: ${data[1]}`;
}
},
xAxis: { type: 'value', name: axisName, splitLine: { show: false } },
yAxis: { type: 'value', name: t('rating'), splitLine: { show: false } },
series: [{ symbolSize: 6, data: dataArr, type: 'scatter', itemStyle: { color: color } }]
});
}
function drawSpeedChart(id, titleText, speedData, width) {
if (!speedData || speedData.length === 0) return;
const chartDom = createChartContainer(id, width);
const myChart = echarts.init(chartDom);
chartInstances.push(myChart);
const categories = ['0-10min', '10-30min', '30-60min', '1-2h', '2-4h', '>4h'];
const values = categories.map(cat => speedData[cat] || 0);
myChart.setOption({
title: { text: titleText, left: 'center', textStyle: { fontSize: 14 } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: categories, axisTick: { alignWithLabel: true } },
yAxis: { type: 'value', name: t('submissions') },
series: [{
name: t('submissions'),
type: 'bar',
data: values,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#2378f7' },
{ offset: 0.7, color: '#2378f7' },
{ offset: 1, color: '#83bff6' }
])
}
}
}]
});
}
function drawStatsSummary(stats) {
if (!stats) return;
const div = `
<div class="roundbox userActivityRoundBox borderTopRound borderBottomRound" style="width: 100%; padding: 1.5em; margin-top: 1em; box-sizing: border-box;">
<h4 style="font-size: 1.2em; color: #333; font-weight: bold; margin-bottom: 1em;">📊 ${currentLang === 'zh' ? '统计摘要' : 'Statistics Summary'}</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #0073e6;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">${currentLang === 'zh' ? '总提交数' : 'Total Submissions'}</div>
<div style="font-size: 1.8em; font-weight: bold; color: #0073e6;">${stats.totalSubmissions || 0}</div>
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #28a745;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">${currentLang === 'zh' ? '已解决题目' : 'Solved Problems'}</div>
<div style="font-size: 1.8em; font-weight: bold; color: #28a745;">${stats.solvedProblems || 0}</div>
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #ffc107;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">${t('streak')}</div>
<div style="font-size: 1.8em; font-weight: bold; color: #ffc107;">${stats.maxStreak || 0} ${t('days')}</div>
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #dc3545;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">${t('points')}</div>
<div style="font-size: 1.8em; font-weight: bold; color: #dc3545;">${stats.totalPoints || 0}</div>
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #6f42c1;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">${currentLang === 'zh' ? 'AC率' : 'AC Rate'}</div>
<div style="font-size: 1.8em; font-weight: bold; color: #6f42c1;">${stats.acRate ? stats.acRate.toFixed(1) + '%' : '0%'}</div>
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #fd7e14;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">${currentLang === 'zh' ? '最高难度' : 'Highest Rating'}</div>
<div style="font-size: 1.8em; font-weight: bold; color: #fd7e14;">${stats.highestRating || '-'}</div>
</div>
</div>
</div>`;
document.getElementById('cf-analytics-dashboard').insertAdjacentHTML('beforeend', div);
}
function drawUnsolvedChart(unsolvedData) {
const unsolvedKeys = Object.keys(unsolvedData);
if (unsolvedKeys.length === 0) return;
const div = `
<div class="roundbox userActivityRoundBox borderTopRound borderBottomRound" style="width: 100%; padding: 1.5em; margin-top: 1em; box-sizing: border-box;">
<h4 style="font-size: 1.2em; color: #333; font-weight: bold; margin-bottom: 0.5em;">${t('unsolved', { n: unsolvedKeys.length })}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${Object.entries(unsolvedData).map(([id, info]) => {
const baseUrl = info.contestId < 10000
? `https://codeforces.com/problemset/problem/${info.contestId}/${info.problemIndex}`
: `https://codeforces.com/problemset/gymProblem/${info.contestId}/${info.problemIndex}`;
return `<a href="${baseUrl}" target="_blank" style="text-decoration: none; color: #d9534f; background: #fdf0ef; padding: 4px 8px; border-radius: 4px; border: 1px solid #d9534f; font-size: 0.85em;">${id}</a>`;
}).join('')}
</div>
</div>`;
document.getElementById('cf-analytics-dashboard').insertAdjacentHTML('beforeend', div);
}
// --- 数据处理逻辑 ---
function processSubmissions(submissions) {
submissions.sort((a, b) => a.creationTimeSeconds - b.creationTimeSeconds);
const res = {
rating: {},
tags: {},
lang: {},
unsolved: {},
verdicts: {},
participantType: {},
attempts: {},
timeline: {},
performance: [],
memoryPerformance: [],
speedAnalysis: {},
stats: {
totalSubmissions: 0,
solvedProblems: 0,
maxStreak: 0,
totalPoints: 0,
acRate: 0,
highestRating: 0
}
};
const problemState = new Map();
const solvedProblems = new Map();
const dailySubmissions = new Map();
let totalAC = 0;
let totalSubmissions = submissions.length;
submissions.forEach(sub => {
const problem = sub.problem;
if (!problem || !problem.contestId) return;
const problemId = `${problem.contestId}${problem.index}`;
// Count total submissions
res.stats.totalSubmissions++;
let v = sub.verdict;
if (v) {
if (v === 'WRONG_ANSWER') v = 'WA';
else if (v === 'TIME_LIMIT_EXCEEDED') v = 'TLE';
else if (v === 'MEMORY_LIMIT_EXCEEDED') v = 'MLE';
else if (v === 'COMPILATION_ERROR') v = 'CE';
else if (v === 'RUNTIME_ERROR') v = 'RE';
else if (v === 'OK') {
v = 'AC';
totalAC++;
}
else if (v === 'PASSED_PRETESTS') v = 'Pretest OK';
res.verdicts[v] = (res.verdicts[v] || 0) + 1;
}
if (sub.author && sub.author.participantType) {
res.participantType[sub.author.participantType] = (res.participantType[sub.author.participantType] || 0) + 1;
}
// Timeline data (monthly)
const date = new Date(sub.creationTimeSeconds * 1000);
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
res.timeline[monthStr] = (res.timeline[monthStr] || 0) + 1;
// Contest speed analysis
if (sub.relativeTimeSeconds && sub.relativeTimeSeconds < 2147483647) {
const minutes = sub.relativeTimeSeconds / 60;
let speedCategory;
if (minutes <= 10) speedCategory = '0-10min';
else if (minutes <= 30) speedCategory = '10-30min';
else if (minutes <= 60) speedCategory = '30-60min';
else if (minutes <= 120) speedCategory = '1-2h';
else if (minutes <= 240) speedCategory = '2-4h';
else speedCategory = '>4h';
res.speedAnalysis[speedCategory] = (res.speedAnalysis[speedCategory] || 0) + 1;
}
if (!problemState.has(problemId)) problemState.set(problemId, { ac: false, tries: 0 });
const pState = problemState.get(problemId);
if (!pState.ac) {
pState.tries++;
if (sub.verdict === 'OK') {
pState.ac = true;
let tryKey = pState.tries === 1 ? 'try1' : pState.tries === 2 ? 'try2' : pState.tries <= 5 ? 'try3_5' : 'tryMore';
res.attempts[tryKey] = (res.attempts[tryKey] || 0) + 1;
// Performance data
if (problem.rating && sub.timeConsumedMillis !== undefined) {
res.performance.push([sub.timeConsumedMillis, problem.rating, problem.name]);
}
// Memory performance data
if (problem.rating && sub.memoryConsumedBytes !== undefined) {
const memoryKB = Math.round(sub.memoryConsumedBytes / 1024);
res.memoryPerformance.push([memoryKB, problem.rating, problem.name]);
}
// Points tracking
if (problem.points) {
res.stats.totalPoints += problem.points;
}
// Track highest rating
if (problem.rating && problem.rating > res.stats.highestRating) {
res.stats.highestRating = problem.rating;
}
solvedProblems.set(problemId, sub);
if (res.unsolved[problemId]) delete res.unsolved[problemId];
} else {
res.unsolved[problemId] = { contestId: problem.contestId, problemIndex: problem.index };
}
}
});
// Process solved problems
solvedProblems.forEach(sub => {
const { rating, tags } = sub.problem;
const lang = sub.programmingLanguage;
if (rating) res.rating[rating] = (res.rating[rating] || 0) + 1;
if (lang) res.lang[lang] = (res.lang[lang] || 0) + 1;
if (tags && tags.length > 0) tags.forEach(tag => { res.tags[tag] = (res.tags[tag] || 0) + 1; });
});
// Calculate stats
res.stats.solvedProblems = solvedProblems.size;
res.stats.acRate = totalSubmissions > 0 ? (totalAC / totalSubmissions * 100) : 0;
// Calculate max streak using monthly timeline data
res.stats.maxStreak = calculateMaxStreakFromTimeline(res.timeline);
return res;
}
function calculateMaxStreakFromTimeline(timelineData) {
if (Object.keys(timelineData).length === 0) return 0;
const months = Object.keys(timelineData).sort();
let maxStreak = 1;
let currentStreak = 1;
for (let i = 1; i < months.length; i++) {
const prevMonth = new Date(months[i - 1] + '-01');
const currMonth = new Date(months[i] + '-01');
// Check if months are consecutive
const diffMonths = (currMonth.getFullYear() - prevMonth.getFullYear()) * 12 +
(currMonth.getMonth() - prevMonth.getMonth());
if (diffMonths === 1) {
currentStreak++;
maxStreak = Math.max(maxStreak, currentStreak);
} else {
currentStreak = 1;
}
}
return maxStreak;
}
// --- 加载状态管理 ---
const showLoading = () => {
hideLoading();
const html = `
<div id="cf-loading-mask" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(255,255,255,0.8); z-index: 9999; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<div id="cf-loading-close" style="position: absolute; top: 20px; right: 30px; font-size: 36px; cursor: pointer; color: #888; font-weight: bold; line-height: 1; transition: color 0.2s;" onmouseover="this.style.color='#333'" onmouseout="this.style.color='#888'" title="Close">×</div>
<div style="width: 50px; height: 50px; border: 5px solid #f3f3f3; border-top: 5px solid #0073e6; border-radius: 50%; animation: cf-spin 1s linear infinite;"></div>
<h3 id="cf-loading-text" style="margin-top: 20px; color: #333; font-family: sans-serif;">${t('loading')}</h3>
<style>@keyframes cf-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
</div>
`;
document.body.insertAdjacentHTML('beforeend', html);
document.getElementById('cf-loading-close').addEventListener('click', hideLoading);
document.getElementById('cf-loading-mask').addEventListener('click', (e) => {
if (e.target.id === 'cf-loading-mask') hideLoading();
});
};
const hideLoading = () => {
const el = document.getElementById('cf-loading-mask');
if (el) el.remove();
};
// --- 主入口 ---
async function init() {
const pathname = window.location.pathname;
const handle = pathname.substring(pathname.lastIndexOf('/') + 1);
if (!handle) return;
showLoading();
try {
const response = await fetch(`https://codeforces.com/api/user.status?handle=${handle}`);
if (!response.ok) throw new Error('API Error');
const json = await response.json();
if (json.status === "OK") {
const processedData = processSubmissions(json.result);
drawCharts(processedData);
}
} catch (err) {
console.error("Failed to load CF Data:", err);
alert(t('fetchError'));
} finally {
hideLoading();
}
}
init();
})();