使用 JQuery Flot 插件纯前端实现一个 Codeforces Rating 表格&颜色效果
写这个东西的背景是两年前刚学 OI 的时候有一个很唐的念头就是给 whk 考试也应用上 OI 的 Rating 状物。我说有这种想法的人大抵是当时觉得自己 whk 特牛逼才能去想这个的吧,生怕自己头顶上的 LGM 没人看见。
但是现在因为各种机缘巧合真的有机会去实现这种东西了,那么请让我们抱着给甲方写 feature 的心态来科研一下 Codeforces 这个 Rating 表吧,也同时把名字颜色这个特性写了。
准备工作
Flot 插件依赖于 JQuery,因此,在加载 Flot 前请务必先加载 JQuery。之后,请先加载核心库。
你可以使用 CDN,或者手动下载文件到本地的方式来安装 Flot,以下是我们需要的文件:
<!-- Flot 核心库 -->
<script src="static/flot/jquery.flot.min.js"></script>
<!-- 平移缩放功能扩展 -->
<script src="static/flot/jquery.flot.navigate.min.js"></script>
<!-- 时间轴扩展 -->
<script src="static/flot/jquery.flot.time.min.js"></script>
手动下载文件请前往 [Flot 的 Github 仓库](Release v0.8.3 · flot/flot),话说最新的 Release 都是 2014 年的了。
安装完成后,打开浏览器的开发者工具,在命令行中输入并回车:
console.log($.plot);
如果输出是一个函数,说明安装成功。如果输出 undefined,请检查脚本加载顺序和路径。
其实现在你可以点进任意一个 Codeforces 用户的主页,保存页面的 .html 源代码后就能找到实现表格的 Javascript
虽然说粘贴过来能直接用,但毕竟不是自己的东西,还存在一些布局上的问题。所以我们仍要尝试自己实现一遍,并做一些改进和加上自己的特色。
这是我们实现的完整代码,需要一个 PHP 框架当后端,Bootstrap 当布局工具:
完整代码
<?php
$username = "sn2mh";
$userClassName = "2025级73.3班";
$nickname = "大象大熊大香蕉";
?>
<div class="col-xl-10 col-lg-10 col-md-11 grid-margin stretch-card">
<div class="card">
<div class="card-body descriptionbody">
<div class="header">
<div class="image">
<img src="https://weavatar.com/avatar/" class="img-fluid rounded-circle" alt="">
</div>
<div class="profile" style="margin-bottom: 5px;">
<span id="profile-title"
class="user-title"
style="margin-bottom: 5px;font-size: 18px;">
</span>
<span id="profile-nickname"
class="nick-name"
style="margin-bottom: 5px;font-size: 27px;">
</span>
<span class="user-name" style="font-size: 14px;">
<?= htmlspecialchars($username) ?>,
<?= htmlspecialchars($userClassName) ?>
</span>
</div>
</div>
<hr>
<div class="selfdesc">
<div class="desc-row">
<span class="title">
<span class="icon"><i class='bi bi-graph-up'></i></span>
<span class="text">等级分</span>
</span>
<span class="desc" id="rating-info">
<span id="current-rating"></span>
<span id="max-rating">
<span>(max: </span>
<span id="max-rating-title">
</span>
<span>, </span>
<span id="max-rating-value">
</span>
<span>)</span>
</span>
</span>
</div>
</div>
</div>
</div>
</div>
<hr>
<div id="chart">
<p>图表中的所有成绩信息仅您自己可见</p>
<div id="chart-container"></div>
</div>
<style>
.permissions-list {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
}
.permission-item {
display: flex;
align-items: center;
padding: 8px 12px;
margin-bottom: 6px;
background: #f8f9fa;
border-radius: 6px;
transition: background-color 0.2s ease;
margin: 5px;
}
.permission-item:hover {
background: #e9ecef;
}
.permission-active {
background: #d1e7dd;
color: white;
}
.permission-active:hover {
background: #c0d3ec;
}
.permission-icon {
margin-right: 10px;
color: #198754;
}
.permission-active .permission-icon {
color: #ffffff;
}
.permission-name {
font-weight: 500;
flex: 1;
}
.permission-code {
font-size: 0.8em;
opacity: 0.7;
}
.accordion-button .title {
display: flex;
align-items: center;
}
.accordion-button .accordion-icon {
transition: transform 0.2s ease;
}
.accordion-button:not(.collapsed) .accordion-icon {
transform: rotate(180deg);
}
.profile {
display: flex;
flex-direction: column;
align-items: start;
justify-content: center;
margin-bottom: 5px;
}
.user-title {
font-weight: bold;
font-family: verdana;
}
.nick-name {
font-weight: bold;
font-family: verdana;
}
.user-name {
color: #666;
}
.Pupil {
color: #008000;
}
.Specialist {
color: #03a89e;
}
.Expert {
color: #0000ff;
}
.Master {
color: #ff8c00;
}
.Grandmaster {
color: #ff0000;
}
.LegendaryGrandmaster {
color: #ff0000;
}
.Unrated {
color: #000000;
font-weight: normal;
}
.LegendaryGrandmaster > .other-letter {
color: #ff0000
}
.LegendaryGrandmaster > .first-letter {
color: #000000
}
#rating-info {
font-family: verdana;
font-weight: bold;
font-size: 14px;
}
.legend {
position: absolute;
right: 0px;
top: 0px;
}
td.legendColorBox {
padding-left: 10px !important;
padding-right: 5px !important;
}
td.legendLabel {
padding-left: 5px !important;
padding-right: 10px !important;
}
#chart-container {
width: 100%;
min-width: 400px;
max-width: 1000px;
height: 300px;
overflow: hidden;
border-radius: 4px;
}
td.legendLabel {
white-space: nowrap;
}
#zoom-tip {
display: none;
position: absolute;
left: 45px;
top: 10px;
z-index: 100;
font-size: 12px;
color: #ffffff;
}
</style>
<script>
// ==========================================
// 全局变量定义
// ==========================================
var tooltipTimer = null;
var isMouseOnTooltip = false;
var chartPlotInstance = null;
var isZoomEnabled = false;
var profileNickname = <?= json_encode($nickname) ?>;
function getTimeStamp(tm) {
let dt = new Date(tm);
return dt.getTime();
}
function escapeHtml(text) {
if (typeof text !== 'string' && typeof text !== 'number') return text;
var map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return String(text).replace(/[&<>"']/g, function(m) {
return map[m];
});
}
function getSafeUrl(url) {
if (!url) return '#';
var trimmedUrl = String(url).trim();
if (/^(https?:\/\/|\/)/.test(trimmedUrl)) {
return escapeHtml(trimmedUrl);
}
return '#';
}
const ratingMarkings = [{
color: '#a00',
yaxis: {
from: 2600
}
},
{
color: '#f33',
yaxis: {
from: 2300,
to: 2599
}
},
{
color: '#ffbb55',
yaxis: {
from: 2000,
to: 2299
}
},
{
color: '#aaf',
yaxis: {
from: 1500,
to: 1999
}
},
{
color: '#77ddbb',
yaxis: {
from: 1000,
to: 1499
}
},
{
color: '#7f7',
yaxis: {
from: 0,
to: 999
}
},
];
const tableTicks = [1000, 1500, 2000, 2300, 2600];
function drawFlot(element, datas, options) {
var plot = $.plot($(element), datas, options);
return plot;
}
function showTooltip(x, y, contents) {
if (tooltipTimer) {
clearTimeout(tooltipTimer);
tooltipTimer = null;
}
var $tooltip = $("#tooltip");
if ($tooltip.length === 0) {
$tooltip = $('<div id="tooltip"></div>').css({
position: 'absolute',
visibility: 'hidden',
border: '1px solid #fdd',
padding: '3px 4px',
'font-size': '12px',
'background-color': '#fee',
'box-shadow': '0 2px 8px rgba(0,0,0,0.15)',
'z-index': 1000,
'max-width': '300px',
'word-wrap': 'break-word',
'pointer-events': 'auto'
}).appendTo("body");
$tooltip.on('mouseenter', function() {
isMouseOnTooltip = true;
if (tooltipTimer) {
clearTimeout(tooltipTimer);
tooltipTimer = null;
}
}).on('mouseleave', function() {
isMouseOnTooltip = false;
hideTooltipWithDelay();
});
}
var isContentChanged = ($tooltip.html() !== contents);
var isHidden = ($tooltip.css('display') === 'none' || $tooltip.css('visibility') === 'hidden');
if (isContentChanged || isHidden) {
$tooltip.html(contents);
$tooltip.css({
display: 'block',
visibility: 'hidden'
});
}
var tooltipWidth = $tooltip.outerWidth(true);
var tooltipHeight = $tooltip.outerHeight(true);
var windowWidth = $(window).width();
var windowHeight = $(window).height();
var scrollTop = $(window).scrollTop();
var scrollLeft = $(window).scrollLeft();
var left = x + 10;
var top = y - tooltipHeight - 5;
if (left + tooltipWidth > windowWidth + scrollLeft) left = x - tooltipWidth - 10;
if (left < scrollLeft) left = scrollLeft + 5;
if (top < scrollTop) top = y + 10;
if (top + tooltipHeight > windowHeight + scrollTop) top = windowHeight + scrollTop - tooltipHeight - 5;
$tooltip.css({
left: left,
top: top,
visibility: 'visible'
}).stop(true, true).fadeIn(200);
}
function hideTooltipWithDelay() {
if (!isMouseOnTooltip) {
if (tooltipTimer) clearTimeout(tooltipTimer);
tooltipTimer = setTimeout(function() {
if (!isMouseOnTooltip) $("#tooltip").stop(true, true).fadeOut(200);
}, 300);
}
}
function bindPlotHoverEvent(plot, element) {
var prev = -1;
$(element).off("plothover").on("plothover", function(event, pos, item) {
if (item) {
var params = data[item.seriesIndex][item.dataIndex];
var total = params[1],
rank = params[2],
delta = params[3];
var title = params[4],
examName = params[5],
examLink = params[6],
formattedTime = params[7];
var safeTitle = escapeHtml(title);
var safeClassName = safeTitle.replace(/\s*/g, "");
var safeFirstChar = safeTitle.charAt(0);
var safeRestChars = safeTitle.slice(1);
var html = '<span class="' + safeClassName + ' user-title">' +
'<span class="first-letter">' + safeFirstChar + '</span>' + safeRestChars +
'</span>: ' + total + '(' + delta + ')';
html += '<br/>Rank: ' + rank;
html += '<br/><a href="' + getSafeUrl(examLink) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(examName) + "</a>";
html += '<br/><span style="color:#666;">' + escapeHtml(formattedTime) + '</span>';
showTooltip(item.pageX, item.pageY, html);
prev = item.dataIndex;
} else {
if (prev !== -1) {
prev = -1;
hideTooltipWithDelay();
}
}
});
}
function showDefaultRatingGraph() {
var options = {
legend: {
show: true,
backgroundOpacity: 0.85,
backgroundColor: "#ffffff"
},
lines: {
show: false
},
points: {
show: false
},
xaxis: {
show: false
},
yaxis: {
show: false
},
grid: {
hoverable: false,
},
hooks: {
drawOverlay: [function(plot, canvasContext) {
var center = plot.getCanvas();
var ctx = canvasContext;
ctx.save();
ctx.fillStyle = "#999";
ctx.font = "20px Arial";
ctx.fillText("暂无数据", center.width / 2, center.height / 2);
ctx.restore();
}]
}
};
chartPlotInstance = drawFlot("#chart-container", [{
label: "<?= $nickname ?>",
data: []
}], options);
var currentTitleClassName = "Unrated", safeProfileTitle = "Unrated";
var safeProfileNickname =escapeHtml(profileNickname);
$("#profile-title")
.removeClass()
.addClass("user-title")
.addClass(currentTitleClassName)
.html(
'<span class="first-letter">' +
safeProfileTitle.charAt(0) +
'</span>' +
'<span class="other-letter">' +
safeProfileTitle.slice(1) +
'</span>'
);
$("#profile-nickname")
.removeClass()
.addClass("nick-name")
.addClass("Unrate")
.html(
'<span class="first-letter">' +
safeProfileNickname.charAt(0) +
'</span>' +
'<span class="other-letter">' +
safeProfileNickname.slice(1) +
'</span>'
);
}
function userRatingGraphRenderer(data) {
var isValidData = data && Array.isArray(data) && data.length > 0 && data[0].length > 0;
if (!isValidData) {
$("#current-rating").text("Unrated");
$("#max-rating").hide();
showDefaultRatingGraph();
return;
}
var minRating = 1e18,
maxRating = -1e18;
var minTimeStamp = 1e18,
maxTimeStamp = -1e18;
var maxRatingTimeStamp = -1e18,
maxRatingTitle = "";
for (let i = 0; i < data[0].length; i++) {
minRating = Math.min(minRating, data[0][i][1]);
if (maxRating <= data[0][i][1]) {
maxRating = data[0][i][1];
maxRatingTimeStamp = data[0][i][0];
maxRatingTitle = data[0][i][4];
}
minTimeStamp = Math.min(minTimeStamp, data[0][i][0]);
maxTimeStamp = Math.max(maxTimeStamp, data[0][i][0]);
}
var currentTitleClassName = String(data[0][data[0].length - 1][4]).replace(/[^a-zA-Z0-9_-]/g, "");
var maxTitleClassName = String(maxRatingTitle).replace(/[^a-zA-Z0-9_-]/g, "");
var safeProfileTitle =
escapeHtml(data[0][data[0].length - 1][4]);
var safeProfileNickname =
escapeHtml(profileNickname);
$("#profile-title")
.removeClass()
.addClass("user-title")
.addClass(currentTitleClassName)
.html(
'<span class="first-letter">' +
safeProfileTitle.charAt(0) +
'</span>' +
'<span class="other-letter">' +
safeProfileTitle.slice(1) +
'</span>'
);
$("#profile-nickname")
.removeClass()
.addClass("nick-name")
.addClass(currentTitleClassName)
.html(
'<span class="first-letter">' +
safeProfileNickname.charAt(0) +
'</span>' +
'<span class="other-letter">' +
safeProfileNickname.slice(1) +
'</span>'
);
var safeMaxTitle = escapeHtml(maxRatingTitle);
$("#current-rating")
.removeClass()
.addClass(currentTitleClassName)
.text(data[0][data[0].length - 1][1]);
$('#max-rating-title')
.removeClass()
.addClass(maxTitleClassName)
.html(
'<span class="first-letter">' +
safeMaxTitle.charAt(0) +
'</span><span class="other-letter">' +
safeMaxTitle.slice(1) +
'</span>'
);
$('#max-rating-value')
.removeClass()
.addClass(maxTitleClassName)
.text(maxRating);
if (minRating > maxRating || maxRating < 0 || minTimeStamp > maxTimeStamp || minTimeStamp < 0 || maxRatingTimeStamp < 0) {
showDefaultRatingGraph();
return;
}
var datas, options;
if (data[0].length == 1) {
datas = [{
label: "<?= $nickname ?>",
data: data[0]
}];
var yMin = Math.max(0, maxRating - Math.min(100, maxRating));
var yMax = maxRating + Math.min(100, maxRating);
options = {
legend: {
show: true,
backgroundOpacity: 0.85,
backgroundColor: "#ffffff",
"position": "ne"
},
lines: {
show: false
},
points: {
show: true
},
xaxis: {
mode: "time",
timeformat: "%Y %b %d",
min: minTimeStamp - 86400000 * 2,
max: maxTimeStamp + 86400000 * 2
},
yaxis: {
min: yMin,
max: yMax,
ticks: [...tableTicks, yMin, yMax].sort((a, b) => a - b)
},
grid: {
hoverable: true,
markings: ratingMarkings
}
};
} else {
datas = [{
label: "<?= $nickname ?>",
data: data[0]
},
{
clickable: false,
hoverable: false,
color: "red",
data: [
[maxRatingTimeStamp, maxRating]
]
}
];
var ratingDelta = maxRating - minRating;
var ratingPadding = Math.max(ratingDelta * 0.2, 100);
var yMax = maxRating + ratingPadding;
var yMin = Math.max(0, minRating - ratingPadding);
var xaxisPanPadding = (maxTimeStamp - minTimeStamp) * 0.1;
var deltaMillis = maxTimeStamp - minTimeStamp;
var years = deltaMillis / (1000 * 60 * 60 * 24 * 365);
var timeFormat = "%Y %b %d";
if (years >= 7) timeFormat = "%Y";
else if (years < 1 && (deltaMillis / (1000 * 60 * 60 * 24)) / 30 <= 7) timeFormat = "%Y %b %d";
else timeFormat = "%Y %b";
options = {
legend: {
show: true,
backgroundOpacity: 0.85,
backgroundColor: "#ffffff",
"position": "ne"
},
lines: {
show: true
},
points: {
show: true
},
xaxis: {
mode: "time",
zoomRange: [172800000, null],
panRange: [minTimeStamp - xaxisPanPadding, maxTimeStamp + xaxisPanPadding],
timeformat: timeFormat
},
yaxis: {
min: yMin,
max: yMax,
ticks: [...tableTicks, yMin, yMax].sort((a, b) => a - b),
zoomRange: [100, null],
panRange: [yMin, yMax]
},
grid: {
hoverable: true,
markings: ratingMarkings
},
zoom: {
interactive: false
},
pan: {
interactive: false
}
};
}
chartPlotInstance = drawFlot("#chart-container", datas, options);
bindPlotHoverEvent(chartPlotInstance, "#chart-container");
var $chart = $("#chart-container");
var healthyWidth = $chart.width();
$chart.css('width', healthyWidth + 'px');
$(window).on('resize.chartResponsive', function() {
// 使用防抖,避免拖拽时频繁重绘卡顿
clearTimeout($.data(this, 'resizeTimer'));
$.data(this, 'resizeTimer', setTimeout(function() {
// 重新获取父容器的当前宽度(此时是用户主动拖拽后的真实宽度)
var newWidth = $chart.parent().width();
$chart.css('width', newWidth + 'px');
// 通知图表重新计算尺寸
if (chartPlotInstance) {
chartPlotInstance.resize();
chartPlotInstance.setupGrid();
chartPlotInstance.draw();
}
}, 15));
});
$chart.off("click").on("click", function() {
if (isZoomEnabled || !chartPlotInstance) return;
isZoomEnabled = true;
var currentOptions = chartPlotInstance.getOptions();
currentOptions.zoom = {
interactive: true
};
currentOptions.pan = {
interactive: true
};
var currentData = chartPlotInstance.getData();
chartPlotInstance = $.plot($chart, currentData, currentOptions);
bindPlotHoverEvent(chartPlotInstance, $chart);
$chart.off("plotpan plotzoom").on("plotpan plotzoom", function(event, p) {
var axes = p.getAxes();
var xaxisOpts = p.getOptions().xaxes[0];
var yaxisOpts = p.getOptions().yaxes[0];
xaxisOpts.min = axes.xaxis.min;
xaxisOpts.max = axes.xaxis.max;
yaxisOpts.min = axes.yaxis.min;
yaxisOpts.max = axes.yaxis.max;
p.setupGrid();
p.draw();
});
$("#zoom-tip").fadeOut(500);
$(this).css("cursor", "default");
});
var mousewheelCount = 0;
$("#chart-container").on("mousewheel DOMMouseScroll", function() {
mousewheelCount++;
if (mousewheelCount >= 3) {
$("#chart-container").append('<span id="zoom-tip">点击图表以启用缩放功能</span>');
$("#zoom-tip").fadeIn(500);
$("#chart-container").off("mousewheel DOMMouseScroll");
}
});
}
var data = [];
data.push([
[getTimeStamp("2026-05-16 23:59:59.999"), 997, 50, "+997", "Pupil", "", "", "2026-05-16 23:59:59.999"],
[getTimeStamp("2026-05-22 23:59:59.999"), 1300, 35, "+303", "Specialist", "", "", "2026-05-22 23:59:59.999"],
[getTimeStamp("2026-05-30 23:59:59.999"), 1540, 200, "+240", "Expert", "", "", "2026-05-30 23:59:59.999"],
[getTimeStamp("2026-06-04 23:59:59.999"), 2000, 8, "+460", "Master", "高一下学期第一次模块考试", "somelink.com", "2026-06-04 23:59:59.999"],
[getTimeStamp("2026-06-06 23:59:59.999"), 2400, 8, "+400", "Grandmaster", "考试", "somelink.com", "2026-06-06 23:59:59.999"],
[getTimeStamp("2026-06-10 23:59:59.999"), 2100, 8, "+300", "Master", "2026 普通高等学校招生全国统一考试", "somelink.com", "2026-06-10 23:59:59.999"],
[getTimeStamp("2026-06-31 23:59:59.999"), 3900, 8, "+300", "Legendary Grandmaster", "2026 普通高等学校招生全国统一考试", "somelink.com", "2026-06-10 23:59:59.999"]
]);
userRatingGraphRenderer(data);
</script>
效果展示




接下来的内容是对这段代码的解释,可能会便于你理解和学习,做出更多的创新。
绘制图表
使用函数 $.plot(placeholder, datas, options) 绘制图表,我们一个一个解释这些参数。
placeholder
Flot 会将图表绘制在给定的 placeholder 中,这是 DOM 中的一个元素,图表被绘制在该元素的区域内。
我们选择一个空的 <div> 来作为绘图区。Flot 要求该元素必须有明确的大小,因此我们最好在样式中写明 width height 之类:
<!-- 等级分图表 -->
<div id="chart">
<div id="chart-container"></div>
</div>
<style>
#chart-container {
width: 100%; // 填满一整行
min-width: 400px; // 避免太窄
max-width: 1000px; // 避免太宽
height: 300px; // 高度固定
overflow: hidden; // 无法显示的部分隐藏
}
</style>
不要给占位符设置花哨的样式,可能会被绘制的图表盖住,也可能直接导致绘制失败。
datas
在接下来的各配置项设置中,一般情况下你无须设置每一个参数, 只需要设置其中几个特定的参数即可,其他参数会使用默认值。
我们会使用到 datas 的这些配置项:
var datas = [
{ // 数组中的一个元素代表一条曲线
label: string // 曲线的图例名,一并绘制在表内
data: data // 点集数组
color: color // 曲线颜色
hoverable: boolean // 是否允许监听鼠标点击事件
hoverable: boolean // 是否允许监听鼠标悬停事件
}
]
点集数组的格式如下:
var data = [
[// 一个数组代表一个数据点
x, // 点的横坐标
y, // 点的纵坐标
..., // 该点的其它信息,绘图时自动被忽略,但之后可被获取。我们通过这个实现悬浮显示考试信息的效果
],
[...],
...
]
这里给一些测试数据:
function getTimeStamp(tm) {
let dt = new Date(tm);
return dt.getTime();
}
var data = [];
data.push([
[getTimeStamp("2026-05-16 23:59:59.999"), 997, 50, "+997", "Pupil", "", "", "2026-05-16 23:59:59.999"],
[getTimeStamp("2026-05-22 23:59:59.999"), 1300, 35, "+303", "Specialist", "", "", "2026-05-22 23:59:59.999"],
[getTimeStamp("2026-05-30 23:59:59.999"), 1540, 200, "+240", "Expert", "", "", "2026-05-30 23:59:59.999"],
[getTimeStamp("2026-06-04 23:59:59.999"), 2000, 8, "+460", "Master", "高一下学期第一次模块考试", "somelink.com", "2026-06-04 23:59:59.999"],
[getTimeStamp("2026-06-06 23:59:59.999"), 2400, 8, "+400", "Grandmaster", "考试", "somelink.com", "2026-06-06 23:59:59.999"],
[getTimeStamp("2026-06-10 23:59:59.999"), 2100, 8, "+300", "Master", "2026 普通高等学校招生全国统一考试", "somelink.com", "2026-06-10 23:59:59.999"],
[getTimeStamp("2026-06-31 23:59:59.999"), 3900, 8, "+300", "Legendary Grandmaster", "2026 普通高等学校招生全国统一考试", "somelink.com", "2026-06-10 23:59:59.999"]
]);
由于我们不能给 data 传对象格式的数据,所以只能记住每个数组下标对应的内容了(
绘制前,我们先对数据进行处理,找出最大最小值等:
// 放入一些测试数据
var data = [];
data.push(<上方的测试数据>);
// 进行数据校验
var isValidData = data && Array.isArray(data) && data.length > 0 && data[0].length > 0;
if (!isValidData) { // 数据格式不合法等,忽略所有数据并渲染默认图表
// 渲染默认图表的逻辑,这个我们之后实现
return;
}
var minRating = 1e18, maxRating = -1e18; // 最低 Rating,最高 Rating
var minTimeStamp = 1e18, maxTimeStamp = -1e18; // 最早时间,最晚时间
var maxRatingTimeStamp = -1e18, maxRatingTitle = ""; // 最高 Rating 对应的时间,称号
for(let i = 0; i < data[0].length; i++) {
minRating = Math.min(minRating, data[0][i][1]);
if(maxRating <= data[0][i][1]) {
maxRating = data[0][i][1];
maxRatingTimeStamp = data[0][i][0];
maxRatingTitle = data[0][i][4];
}
minTimeStamp = Math.min(minTimeStamp, data[0][i][0]);
maxTimeStamp = Math.max(maxTimeStamp, data[0][i][0]);
}
// 再次进行数据校验
if (minRating > maxRating || maxRating < 0 || minTimeStamp > maxTimeStamp || minTimeStamp < 0 || maxRatingTimeStamp < 0) {
// 渲染默认图表的逻辑
return;
}
接下来设置要传递给 Flot 的 datas 数组:
datas = [
{label: "<?= $nickname ?>", data: data[0]},
{clickable: false, hoverable: false, color: "red", data: [[maxRatingTimeStamp, maxRating]]}
];
<?= $nickname ?> 用了一点 PHP 当后端,替换成你的后端语言或者你喜欢的名字即可。
这里有一个小巧思:第二条线段只渲染一个红色的 Rating 最高点,该点会盖住第一条线段在这个位置的黄点(黄色是默认颜色),也就实现了突出显示 Rating 最高点的效果。
我们把这个点设为不监听鼠标点击事件,不监听鼠标悬停事件,这样获取点的信息时还是统一获取黄点的信息。
options
使用 options 数组中的部分选项配置图表效果,以下是我们要用到的部分:
var options = {
legend: { // 图例配置
show: boolean // 是否展示图例
position: "ne" or "nw" or "se" or "sw" // 图例位置,"ne" 东北,即右上角
backgroundColor: null or color // 图例背景色
backgroundOpacity: number between 0 and 1 // 图例背景色透明度
},
lines: { show: boolean },// 是否用直线连接各数据点
points: { show: boolean },// 是否绘制各数据点
xaxis, yaxis { // 横纵坐标轴设置
mode: nullor "time" //数轴是否为时间模式
min: null or number //数轴最小值
max: null or number //数轴最大值
ticks: nullor number or ticks array or (fn: range -> ticks array) // 各分割线位置
tickFormat: // (若为时间模式)时间格式
zoomRange: [] // 缩放范围
panRange [] // 拖拽范围
}
grid: { // 背景栅格设置
hoverable: boolean, // 是否监听鼠标悬停事件,我们通过这个实现悬浮显示考试信息的效果
markings: [] // 在背景上绘画出一些矩形,我们用这个绘制各 Rating 等级的背景色
},
zoom: { interactive: boolean }, // 是否启用缩放
pan: { interactive: boolean } // 是否启用拖拽
hooks: {
drawOverlay: [] // 绘制结束后在最上层在绘制某些东西,传入函数,我们将用它渲染 ”暂无数据“ 字样
}
};
我们的配置如下:
// 计算一些必要参数
// rating 极差
var ratingDelta = maxRating - minRating;
// 上下留白
var ratingPadding = Math.max(ratingDelta * 0.2, 100);
var yMax = maxRating + ratingPadding;
var yMin = Math.max(0, minRating - ratingPadding);
// 缩放拖动时左右留白
var xaxisPanPadding = (maxTimeStamp - minTimeStamp) * 0.1;
// 时间显示格式
var deltaMillis = maxTimeStamp - minTimeStamp;
var years = deltaMillis / (1000 * 60 * 60 * 24 * 365);
var timeFormat = "%Y %b %d"; // 默认显示年月日
if (years >= 7) timeFormat = "%Y"; // 只显示年份
else if (years < 1 && (deltaMillis / (1000 * 60 * 60 * 24)) / 30 <= 7) timeFormat = "%Y %b %d"; // 显示年月日
else timeFormat = "%Y %b"; // 显示年月
var options = {
legend: {
show: true, // 展示图例
backgroundOpacity: 0.85, // 背景稍稍透明
backgroundColor: "#ffffff", // 白色背景色
"position": "ne" // 图例在右上角
},
lines: {
show: true // 绘制直线
},
points: {
show: true // 绘制数据点
},
xaxis: { // 横坐标轴设置
mode: "time", // 时间模式
zoomRange: [172800000, null], // 缩放范围,不设最大显示的时间差(这里 y 轴会约束),时间戳格式
panRange: [minTimeStamp - xaxisPanPadding, maxTimeStamp + xaxisPanPadding], // 拖拽的范围,让左右留白
timeformat: timeFormat // 上面计算的时间格式
},
yaxis: { // 纵坐标轴设置
min: yMin_calc, // 上方计算的范围,让上下留白
max: yMax_calc,
ticks: [...tableTicks, yMin_calc, yMax_calc].sort((a, b) => a - b), // 分割线位置,让顶部和底部也显示
zoomRange: [100, null], // 缩放范围,最小差值 100 单位
panRange: [yMin_calc, yMax_calc] // 拖拽范围,与 初始范围相同
},
grid: {
hoverable: true, // 允许监听 plothover 事件
markings: ratingMarkings // 渲染各个分数段的矩形
},
zoom: {
interactive: false // 初始时不允许缩放
},
pan: {
interactive: false // 初始时不允许拖拽
}
};
如果数据中只有一个数据点,我们作特殊处理:
这里我们不设置 zoom,pan,zoomRange, panRange 等属性,是因为只有一个数据点就无需再支持缩放拖拽了,我们之后也不会给这个图表监听任何事件。
// 上下留一个默认值
var yMin = Math.max(0, maxRating - Math.min(100, maxRating));
var yMax = maxRating + Math.min(100, maxRating);
options = {
legend: {
show: true,
backgroundOpacity: 0.85,
backgroundColor: "#ffffff",
"position": "ne"
},
lines: {
show: false // 不展示直线
},
points: {
show: true
},
xaxis: {
mode: "time",
timeformat: "%Y %b %d", // 年月日
min: minTimeStamp - 86400000 * 2, // 左右留一块默认值
max: maxTimeStamp + 86400000 * 2
},
yaxis: {
min: yMin,
max: yMax,
ticks: [...tableTicks, yMin, yMax].sort((a, b) => a - b)
},
grid: {
hoverable: true,
markings: ratingMarkings
}
};
如果数据异常或为空,我们渲染一个默认图表:
var options = {
legend: {
show: true,
backgroundOpacity: 0.85,
backgroundColor: "#ffffff"
},
// 全部不显示
lines: { show: false },
points: { show: false },
xaxis: { show: false },
yaxis: { show: false },
grid: { hoverable: false },
hooks: { // 绘图完成后,在中心处绘制一个 “暂无数据”,使用 canvas 内置方法手动实现
drawOverlay: [function(plot, canvasContext) {
var center = plot.getCanvas();
var ctx = canvasContext;
ctx.save();
ctx.fillStyle = "#999";
ctx.font = "20px Arial";
ctx.fillText("暂无数据", center.width / 2, center.height / 2);
ctx.restore();
}]
}
};
有了 placeholder,datas,options,接下来就可以进行渲染了。
我们把渲染操作封装到一个函数里,该函数返回一个 plot 对象供我们之后使用。
function drawFlot(element, datas, options) {
var plot = $.plot($(element), datas, options);
return plot;
}
下面来实现移动到数据点上展示的悬浮提示栏效果。
悬浮提示栏
Flot 提供一个名为 plothover 的事件,监听鼠标是否移动到了数据点上,并可自定义事件触发后的行为。
绑定事件 bindPlotHoverEvent 方法
首先给我们的 placeholder 绑定事件,并处理提示栏的内容:
function bindPlotHoverEvent(plot, element) {
var prev = -1; // 先前移动到的数据点编号,用于标记是否存在被渲染的悬浮框
// .off 清除之前的事件,.on 绑定新的事件,避免多个事件堆叠
$(element).off("plothover").on("plothover", function(event, pos, item) { // 自定义行为
if (item) { // 若是移动到了数据点上
// 这就是上文中提到的 data 数组一个数据点的信息,在这里原封不动
var params = data[item.seriesIndex][item.dataIndex];
var total = params[1], // 下标 1 存着当前 Rating
rank = params[2], // 下标 2 存着排名等,以此类推
delta = params[3];
var title = params[4],
examName = params[5],
examLink = params[6],
formattedTime = params[7];
// 由于要向 DOM 中放置内容,虽然这些信息都是从后端来的,但是还是要过滤一遍尽可能防 XSS,我们自己实现了两个方法
var safeTitle = escapeHtml(title);
var safeClassName = safeTitle.replace(/\s*/g, "");
var safeFirstChar = safeTitle.charAt(0);
var safeRestChars = safeTitle.slice(1);
// 拼接 html
// 给用户昵称设置基于对应等级名的 class,之后放一个全局样式就能实现昵称颜色的效果了,这个我们之后会细说
var html = '<span class="' + safeClassName + ' user-title">' +
'<span class="first-letter">' + safeFirstChar + '</span>' + safeRestChars +
'</span>: ' + total + '(' + delta + ')';
html += '<br/>Rank: ' + rank;
html += '<br/><a href="' + getSafeUrl(examLink) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(examName) + "</a>";
html += '<br/><span style="color:#666;">' + escapeHtml(formattedTime) + '</span>';
// 渲染悬浮框
// 传入数据点坐标,提示栏信息
showTooltip(item.pageX, item.pageY, html);
prev = item.dataIndex; // 标记已渲染
} else {
if (prev !== -1) { // 若不在数据点上且有已渲染的悬浮框
prev = -1; // 清除标记
hideTooltipWithDelay(); // 设置自动消失时间
}
}
});
}
// 两个字符串过滤方法,防 XSS
function escapeHtml(text) {
if (typeof text !== 'string' && typeof text !== 'number') return text;
var map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return String(text).replace(/[&<>"']/g, function(m) {
return map[m];
});
}
function getSafeUrl(url) {
if (!url) return '#';
var trimmedUrl = String(url).trim();
if (/^(https?:\/\/|\/)/.test(trimmedUrl)) {
return escapeHtml(trimmedUrl);
}
return '#';
}
渲染悬浮框 showTooltip hideTooltipWithDelay 方法
接收来自 bindPlotHoverEvent 的坐标,计算合理的提示栏展示位置,并处理鼠标移出,移入提示栏的定时消失逻辑。
// 引入两个全局变量
var tooltipTimer = null; // 是否正在进行消失计时
var isMouseOnTooltip = false; // 鼠标是否在提示栏上
function showTooltip(x, y, contents) {
if (tooltipTimer) { // 清除上一次处理残余的状态
clearTimeout(tooltipTimer); // 这是 JQuery 方法
tooltipTimer = null;
}
var $tooltip = $("#tooltip");
if ($tooltip.length === 0) { // 未渲染
// 设置样式
$tooltip = $('<div id="tooltip"></div>').css({
position: 'absolute',
visibility: 'hidden',
border: '1px solid #fdd',
padding: '3px 4px',
'font-size': '12px',
'background-color': '#fee',
'box-shadow': '0 2px 8px rgba(0,0,0,0.15)',
'z-index': 1000,
'max-width': '300px',
'word-wrap': 'break-word',
'pointer-events': 'auto'
}).appendTo("body");
// 绑定鼠标移入提示栏事件
$tooltip.on('mouseenter', function() {
isMouseOnTooltip = true; // 标记
if (tooltipTimer) { // 清除消失倒计时
clearTimeout(tooltipTimer);
tooltipTimer = null;
}
}).on('mouseleave', function() { // 移出提示栏事件
isMouseOnTooltip = false; // 标记
hideTooltipWithDelay(); // 开始消失倒计时
});
}
// 渲染优化:仅在提示框内容有变化时修改 DOM 中的 html
// 另外,由于接下来要计算提示框位置,需要提示框宽高,因此这里的 display 绝对不能是 none,我们进行特殊处理
// 如果元素被隐藏,我们也进行内容更新,应对一些边界情况
// 这些避免了高频触发 plothover 时频繁地重写 DOM 节点,同时不进行不必要的更新也保证了提示框位置固定,避免抖动或者闪动
var isContentChanged = ($tooltip.html() !== contents);
var isHidden = ($tooltip.css('display') === 'none' || $tooltip.css('visibility') === 'hidden');
if (isContentChanged || isHidden) {
$tooltip.html(contents);
$tooltip.css({
display: 'block',
visibility: 'hidden'
});
}
// 计算提示框位置
var tooltipWidth = $tooltip.outerWidth(true);
var tooltipHeight = $tooltip.outerHeight(true);
var windowWidth = $(window).width();
var windowHeight = $(window).height();
var scrollTop = $(window).scrollTop();
var scrollLeft = $(window).scrollLeft();
var left = x + 10;
var top = y - tooltipHeight - 5;
if (left + tooltipWidth > windowWidth + scrollLeft) left = x - tooltipWidth - 10;
if (left < scrollLeft) left = scrollLeft + 5;
if (top < scrollTop) top = y + 10;
if (top + tooltipHeight > windowHeight + scrollTop) top = windowHeight + scrollTop - tooltipHeight - 5;
$tooltip.css({ // 设置为可见,给用户展示
left: left,
top: top,
visibility: 'visible'
}).stop(true, true).fadeIn(200); // 淡入效果
}
// 添加消失计时器方法,一个异步 setTimeout
function hideTooltipWithDelay() {
if (!isMouseOnTooltip) {
if (tooltipTimer) clearTimeout(tooltipTimer);
tooltipTimer = setTimeout(function() {
if (!isMouseOnTooltip) $("#tooltip").stop(true, true).fadeOut(200);
}, 300);
}
}
缩放与拖拽
Codeforces 的 Rating 图表有一个很实用的功能:可以对图表进行缩放、拖拽,方便查看某一段时间内的数据变化。
Flot 本身已经提供了对应插件,因此我们只需要加载:
<script src="static/flot/jquery.flot.navigate.min.js"></script>
然后在配置中加入:
zoom: {
interactive: true
},
pan: {
interactive: true
}
即可启用。
不过这里我们没有直接开启,而是采用了 延迟开启 的方案。
因为对于大部分用户而言,查看 Rating 图表只是一个阅读行为。如果默认开启缩放,那么鼠标滚轮滚动页面时,很容易误触发图表缩放,反而影响体验。
因此初始状态下:
zoom: {
interactive: false
},
pan: {
interactive: false
}
用户点击图表后才真正开启。
$chart.off("click").on("click", function() {
if (isZoomEnabled || !chartPlotInstance) return;
isZoomEnabled = true;
var currentOptions = chartPlotInstance.getOptions();
currentOptions.zoom = {
interactive: true
};
currentOptions.pan = {
interactive: true
};
var currentData = chartPlotInstance.getData();
chartPlotInstance = $.plot($chart, currentData, currentOptions);
});
这里有一个需要注意的地方:
Flot 的 navigate 插件并不会动态监听配置变化。
也就是说:
plot.getOptions().zoom.interactive = true;
是没有效果的。
必须重新执行一次:
$.plot(...)
重新初始化图表,让插件重新绑定事件。
所以我们才会取出当前数据和配置,再重新绘制一次图表。
同步缩放状态
用户缩放或拖拽后,图表会产生新的坐标轴范围。
如果不手动同步,当下一次重新绘图时会恢复到初始状态,效果很诡异。
因此我们监听:
plotpan
plotzoom
两个事件。
$chart.off("plotpan plotzoom")
.on("plotpan plotzoom", function(event, p) {
var axes = p.getAxes();
var xaxisOpts = p.getOptions().xaxes[0];
var yaxisOpts = p.getOptions().yaxes[0];
xaxisOpts.min = axes.xaxis.min;
xaxisOpts.max = axes.xaxis.max;
yaxisOpts.min = axes.yaxis.min;
yaxisOpts.max = axes.yaxis.max;
p.setupGrid();
p.draw();
});
每次缩放结束后,将当前坐标轴范围写回配置中。
这样后续的重绘是基于用户当前看到的视图进行移动,这才是正确的效果
缩放功能提示
很多用户其实不知道图表支持缩放,但如果页面上直接写一行:
点击图表开启缩放
又会显得有些突兀。
因此我们采用一种比较自然的提示方式:
只有当用户连续滚动鼠标滚轮数次后,才认为用户有查看细节的需求,此时再展示提示。
var mousewheelCount = 0;
$("#chart-container").on("mousewheel DOMMouseScroll", function() {
mousewheelCount++;
if (mousewheelCount >= 3) {
$("#chart-container").append(
'<span id="zoom-tip">点击图表以启用缩放功能</span>'
);
$("#zoom-tip").fadeIn(500);
$("#chart-container")
.off("mousewheel DOMMouseScroll");
}
});
这样既不会影响第一次浏览页面的用户,也能在真正需要时给予引导。
开启缩放后再隐藏提示:
$("#zoom-tip").fadeOut(500);
整个交互过程会显得比较自然。
防止边框抖动与响应式更新
最折磨人的 Bug 之一。
现象如下:
- 页面初始加载正常。
- 点击图表开启缩放。
- 图表右侧边框突然向左移动几个像素。
- 窗口大小变化时现象更加明显。
问题好像是 Flot 重绘操作 DOM 时由于某些原因浏览器会在图表旁生成一个被盖住的滚动条(?出现后,可视区域宽度发生变化。
原本:
width: 100%;
会重新计算父容器宽度。
而 Flot 又会重新计算 Canvas 大小。
最终导致图表右边框产生“内缩”现象。
解决方案是在图表状态正常时,先记录一个健康宽度:
var healthyWidth = $chart.width();
用户点击尝试进入缩放时,把原来的 width: 100% 改成固定像素值:
$chart.css(
'width',
healthyWidth + 'px'
);
这样即使浏览器后续出现滚动条,图表宽度也不会发生变化,边框就不会抖动。
但是,如果直接固定宽度,又会失去响应式布局能力。
因此我们额外监听窗口 Resize 事件:
$(window).on('resize.chartResponsive', function() {
clearTimeout($.data(this, 'resizeTimer'));
$.data(this, 'resizeTimer', setTimeout(function() {
// 重新获取父容器的当前宽度(此时是用户主动拖拽后的真实宽度)
var newWidth = $chart.parent().width();
$chart.css('width', newWidth + 'px');
// 通知图表重新计算尺寸
if (chartPlotInstance) {
chartPlotInstance.resize();
chartPlotInstance.setupGrid();
chartPlotInstance.draw();
}
}, 15));
});
这里顺便加了一个简单防抖,否则用户拖动浏览器窗口时会不断重绘图表,造成明显卡顿。
最终实现效果:
- 进入缩放模式时边框不会抖动
- 浏览器窗口变化时图表仍然保持响应式
- 重绘次数最少
名字,称号的颜色显示
Codeforces 最大的特色之一,就是不同 Rating 对应不同颜色。
CF 的名字称号字体是 verdana,这个字体大部分系统都内置,之后我们也用这个
这里的想法是:由于我们绘制图表时一定要处理 Rating 历史数据,因此确定最大值,当前值,修改 CSS 进行颜色渲染的工作前端做即可,后端只向前端发送数据而不关心数据内容。
实现方式其实很简单,首先定义对应样式:
.user-title {
font-weight: bold;
font-family: verdana;
}
.nick-name {
font-weight: bold;
font-family: verdana;
}
.user-name {
color: #666;
}
.Pupil {
color: #008000;
}
.Specialist {
color: #03a89e;
}
.Expert {
color: #0000ff;
}
.Master {
color: #ff8c00;
}
.Grandmaster {
color: #ff0000;
}
.LegendaryGrandmaster {
color: #ff0000;
}
.Unrated {
color: #000000;
font-weight: normal;
}
然后把称号名称转换为合法 CSS 类名:
var safeClassName = safeTitle.replace(/\s*/g, ""); // 去掉空格,为了处理 LGM 的情况
例如:
Legendary Grandmaster
变成:
LegendaryGrandmaster
之后直接拼接:
html += '<span class="' + safeClassName + '">';
这样即可自动继承对应颜色。
Legendary Grandmaster 特殊处理
LGM 的第一个字符是黑色,后面的字符是红色。因此额外拆分出第一个字符:
<span class="LegendaryGrandmaster">
<span class="first-letter">L</span>
<span class="other-letter">egendary Grandmaster</span>
</span>
对应样式:
.LegendaryGrandmaster > .first-letter {
color: #000000;
}
.LegendaryGrandmaster > .other-letter {
color: #ff0000;
}
最终即可获得与 Codeforces 基本一致的显示效果。
“等级分” 信息栏
图表上方有一个等级分信息栏:
等级分 2700(max: Legendary Grandmaster, 2700)
用于展示当前 Rating ,历史最高 Rating 和历史最高称号
在前端遍历数据时,我们已经顺便统计出了:
maxRating
maxRatingTitle
以及用户当前 Rating 对应的数据点,也就是数据数组的最后一个元素:
data[0][data[0].length - 1]
因此直接用这些数据更新 DOM 即可:
var currentTitleClassName = String(data[0][data[0].length - 1][4]).replace(/[^a-zA-Z0-9_-]/g, "");
var maxTitleClassName = String(maxRatingTitle).replace(/[^a-zA-Z0-9_-]/g, "");
var safeProfileTitle =
escapeHtml(data[0][data[0].length - 1][4]);
var safeProfileNickname =
escapeHtml(profileNickname);
$("#current-rating")
.addClass(currentTitleClassName)
.text(data[0][data[0].length - 1][1]);
历史最高称号:
$('#max-rating-title')
.removeClass()
.addClass(maxTitleClassName)
.html(
'<span class="first-letter">' +
safeMaxTitle.charAt(0) + // 用方法截取第一个字符和剩余字符
'</span><span class="other-letter">' +
safeMaxTitle.slice(1) +
'</span>'
);
历史最高 Rating:
$('#max-rating-value')
.addClass(maxTitleClassName)
.text(maxRating);
这样颜色也会自动继承对应等级。
用户称号与昵称颜色显示
用户头像右侧的称号与昵称颜色与 Codeforces 保持一致。
页面加载时,后端仅提供用户昵称,前端根据称号动态生成对应 CSS 类名:
var currentTitleClassName = String(data[0][data[0].length - 1][4]).replace(/[^a-zA-Z0-9_-]/g, "");
与上文同理应用到称号与昵称元素:
$("#profile-title")
.removeClass()
.addClass("user-title")
.addClass(currentTitleClassName)
.html(
'<span class="first-letter">' +
safeProfileTitle.charAt(0) +
'</span>' +
'<span class="other-letter">' +
safeProfileTitle.slice(1) +
'</span>'
);
$("#profile-nickname")
.removeClass()
.addClass("nick-name")
.addClass(currentTitleClassName)
.html(
'<span class="first-letter">' +
safeProfileNickname.charAt(0) +
'</span>' +
'<span class="other-letter">' +
safeProfileNickname.slice(1) +
'</span>'
);
至此,一个完整的 Rating 图表已经基本实现完成。
bye~!

浙公网安备 33010602011771号