使用 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 = {
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#039;'
        };
        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>

效果展示

CFR1

CFR

CFR3

CFR4

接下来的内容是对这段代码的解释,可能会便于你理解和学习,做出更多的创新。

绘制图表

使用函数 $.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 // 初始时不允许拖拽
    }
};

如果数据中只有一个数据点,我们作特殊处理:

这里我们不设置 zoompanzoomRangepanRange 等属性,是因为只有一个数据点就无需再支持缩放拖拽了,我们之后也不会给这个图表监听任何事件。

// 上下留一个默认值
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();
        }]
    }
};

有了 placeholderdatasoptions,接下来就可以进行渲染了。

我们把渲染操作封装到一个函数里,该函数返回一个 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 = {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#039;'
    };
    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 之一。

现象如下:

  1. 页面初始加载正常。
  2. 点击图表开启缩放。
  3. 图表右侧边框突然向左移动几个像素。
  4. 窗口大小变化时现象更加明显。

问题好像是 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~!

posted @ 2026-06-18 19:49  hm2ns  阅读(13)  评论(0)    收藏  举报