console
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>从第28名到第1名 - 四年高科技版</title>
<style>
html, body {
margin: 0; padding: 0;
background: #03060d;
overflow: hidden;
}
body {
display: flex; align-items: center; justify-content: center;
height: 100vh;
}
canvas {
width: 100vw; height: auto; max-height: 100vh;
display: block; background: #03060d;
}
</style>
</head>
<body>
<canvas id="stage" width="1920" height="1080"></canvas>
<script>
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const W = 1920, H = 1080;
// ================= 数据 =================
const data = [
{ label: '入学考', date: '2022.09', rank: 28, classRank: 3 },
{ label: '初一上期中', date: '2022.11', rank: 46, classRank: 3 },
{ label: '初一上期末', date: '2023.01', rank: 22, classRank: 1 },
{ label: '初一下期中', date: '2023.05', rank: 11, classRank: 1 },
{ label: '初一下期末', date: '2023.07', rank: 2, classRank: 1 },
{ label: '初二上期中', date: '2023.11', rank: 1, classRank: 1 },
{ label: '初二上期末', date: '2024.01', rank: 7, classRank: 1 },
{ label: '初二下三月', date: '2024.04', rank: 9, classRank: 1 },
{ label: '初二下期中', date: '2024.05', rank: 1, classRank: 1 },
{ label: '初二下期末', date: '2024.07', rank: 3, classRank: 1 },
{ label: '初三上九月', date: '2024.09', rank: 2, classRank: 1 },
{ label: '初三上期中', date: '2024.11', rank: 1, classRank: 1 },
{ label: '初三上期末', date: '2025.01', rank: 2, classRank: 1 },
{ label: '初三下三月', date: '2025.04', rank: 1, classRank: 1 },
{ label: '初三下期中', date: '2025.05', rank: 1, classRank: 1 },
{ label: '初三下期末', date: '2025.07', rank: 1, classRank: 1 },
{ label: '初四九月月考', date: '2025.10', rank: 2, classRank: 1 },
{ label: '初四统考', date: '2025.11', rank: 1, classRank: 1 },
{ label: '12月月考', date: '2025.12', rank: 1, classRank: 1 },
{ label: '初四下期末', date: '2026.01', rank: 3, classRank: 1 },
{ label: '三月份月考', date: '2026.03', rank: 3, classRank: 1 },
{ label: '南岗一模', date: '2026.03', rank: 16, classRank: 1 },
{ label: '南岗二模', date: '2026.04', rank: 21, classRank: 2 },
{ label: '南岗三模', date: '2026.06', rank: 13, classRank: 1 },
{ label: '中考', date: '2026.06', rank: 1, classRank: 1 }
];
const timeMarks = [
{ i: 0, text: '2022.09' },
{ i: 2, text: '2023.01' },
{ i: 4, text: '2023.07' },
{ i: 6, text: '2024.01' },
{ i: 9, text: '2024.07' },
{ i: 12, text: '2025.01' },
{ i: 15, text: '2025.07' },
{ i: 19, text: '2026.01' },
{ i: 24, text: '2026.06' }
];
const TOTAL = 60;
const CURVE_START = 2;
const CURVE_END = 38;
const CARD_START = 38;
const FINAL_START = 43;
const LEFT = 170, RIGHT = 1750;
const TOP = 250, BOTTOM = 860;
const CHART_W = RIGHT - LEFT;
const CHART_H = BOTTOM - TOP;
// ================= 工具 =================
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
function easeOutCubic(x){ return 1 - Math.pow(1 - x, 3); }
function easeOutBack(x){
const c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
}
function roundRect(x, y, w, h, r){
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function rankToY(rank){
const r = Math.max(1, Math.min(50, rank));
return TOP + ((r - 1) / 49) * CHART_H;
}
function classRankToY(rank){
const r = Math.max(1, Math.min(3, rank));
const top = TOP + 40, bottom = TOP + 150;
return top + ((r - 1) / 2) * (bottom - top);
}
function indexToX(i){ return LEFT + (i / (data.length - 1)) * CHART_W; }
function getPos(t){
const p = clamp01((t - CURVE_START) / (CURVE_END - CURVE_START));
return p * (data.length - 1);
}
// ================= 背景 =================
const stars = [];
for (let i = 0; i < 240; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 2.2 + 0.3,
speed: Math.random() * 12 + 2,
phase: Math.random() * Math.PI * 2,
amp: Math.random() * 28 + 5,
hue: Math.random() < 0.7 ? 200 : 40
});
}
const nebulas = [
{ x: 380, y: 280, r: 520, hue: 200, speed: 0.13, phase: 0 },
{ x: 1540, y: 740, r: 600, hue: 215, speed: 0.09, phase: 2 },
{ x: 960, y: 180, r: 460, hue: 45, speed: 0.07, phase: 4 }
];
function drawNebula(t){
ctx.save();
ctx.globalCompositeOperation = 'screen';
for (const n of nebulas) {
const cx = n.x + Math.sin(t * n.speed + n.phase) * 90;
const cy = n.y + Math.cos(t * n.speed * 0.8 + n.phase) * 60;
const g = ctx.createRadialGradient(cx, cy, 20, cx, cy, n.r);
if (n.hue === 45) {
g.addColorStop(0, 'rgba(251,191,36,0.07)');
g.addColorStop(0.5, 'rgba(251,191,36,0.022)');
g.addColorStop(1, 'rgba(251,191,36,0)');
} else {
g.addColorStop(0, 'rgba(56,189,248,0.085)');
g.addColorStop(0.5, 'rgba(56,189,248,0.028)');
g.addColorStop(1, 'rgba(56,189,248,0)');
}
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
ctx.restore();
}
function drawStars(t){
for (const s of stars) {
const x = (s.x + t * s.speed) % W;
const y = s.y + Math.sin(t * 0.6 + s.phase) * s.amp;
const twinkle = 0.5 + 0.5 * Math.sin(t * 2.2 + s.phase);
const a = 0.12 + 0.28 * twinkle;
ctx.beginPath();
ctx.arc(x, y, s.r, 0, Math.PI * 2);
ctx.fillStyle = s.hue === 45
? `rgba(253,224,140,${a})`
: `rgba(147,197,253,${a})`;
ctx.fill();
}
}
function drawDotMatrix(t){
ctx.save();
const spacing = 64;
for (let row = 0; row * spacing < H + spacing; row++) {
for (let col = 0; col * spacing < W + spacing; col++) {
const x = col * spacing + (row % 2) * spacing / 2;
const y = row * spacing;
const phase = (x + y) * 0.006 + t * 0.5;
const a = 0.035 + 0.055 * (0.5 + 0.5 * Math.sin(phase));
ctx.beginPath();
ctx.arc(x, y, 1.4, 0, Math.PI * 2);
ctx.fillStyle = `rgba(56,189,248,${a})`;
ctx.fill();
}
}
ctx.restore();
}
function drawRadar(t){
ctx.save();
ctx.strokeStyle = 'rgba(56,189,248,0.05)';
ctx.lineWidth = 1;
for (let r = 200; r <= 800; r += 150) {
ctx.beginPath();
ctx.arc(960, 540, r, 0, Math.PI * 2);
ctx.stroke();
}
const ang = t * 0.35;
const g = ctx.createLinearGradient(960, 540,
960 + Math.cos(ang) * 820, 540 + Math.sin(ang) * 820);
g.addColorStop(0, 'rgba(56,189,248,0.10)');
g.addColorStop(1, 'rgba(56,189,248,0)');
ctx.strokeStyle = g;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(960, 540);
ctx.lineTo(960 + Math.cos(ang) * 820, 540 + Math.sin(ang) * 820);
ctx.stroke();
ctx.restore();
}
function drawPerspectiveGrid(t){
const horizon = 800;
ctx.save();
ctx.globalAlpha = 0.14;
ctx.strokeStyle = 'rgba(56,189,248,0.7)';
ctx.lineWidth = 1.1;
for (let i = 0; i < 16; i++) {
const p = ((i + (t * 0.22) % 1) / 16);
const y = horizon + Math.pow(p, 2.2) * (H - horizon + 220);
if (y > H + 50) continue;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
}
const vpX = W / 2;
for (let i = -12; i <= 12; i++) {
ctx.beginPath();
ctx.moveTo(vpX + i * 26, horizon);
ctx.lineTo(vpX + i * 220, H + 60);
ctx.stroke();
}
ctx.restore();
}
function drawBackground(t){
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, '#03060d');
bg.addColorStop(0.5, '#060b17');
bg.addColorStop(1, '#0a1324');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
drawDotMatrix(t);
drawNebula(t);
drawStars(t);
drawRadar(t);
drawPerspectiveGrid(t);
const vg = ctx.createRadialGradient(W/2, H/2, 220, W/2, H/2, 1100);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
// ================= HUD =================
function drawHUD(t){
const m = 28, len = 52;
ctx.save();
ctx.strokeStyle = 'rgba(56,189,248,0.45)';
ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(56,189,248,0.5)';
ctx.shadowBlur = 10;
const corners = [
[m, m + len, m, m, m + len, m],
[W - m - len, m, W - m, m, W - m, m + len],
[m, H - m - len, m, H - m, m + len, H - m],
[W - m - len, H - m, W - m, H - m, W - m, H - m - len]
];
corners.forEach(c => {
ctx.beginPath();
ctx.moveTo(c[0], c[1]);
ctx.lineTo(c[2], c[3]);
ctx.lineTo(c[4], c[5]);
ctx.stroke();
});
ctx.restore();
ctx.save();
ctx.font = '19px "Consolas", "Microsoft YaHei", monospace';
const blink = Math.sin(t * 4) > 0;
ctx.fillStyle = blink ? '#4ade80' : '#14532d';
ctx.shadowColor = '#4ade80';
ctx.shadowBlur = blink ? 12 : 0;
ctx.beginPath();
ctx.arc(92, 42, 5, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(148,197,253,0.78)';
ctx.textAlign = 'left';
ctx.fillText('SYSTEM ONLINE', 110, 48);
const tc = `T+${String(Math.floor(t)).padStart(2,'0')}:${String(Math.floor((t % 1) * 60)).padStart(2,'0')}`;
ctx.textAlign = 'right';
ctx.fillText(tc, W - 92, 48);
ctx.textAlign = 'left';
ctx.fillText('DATA STREAM ACTIVE', 92, H - 36);
ctx.textAlign = 'right';
ctx.fillText('RANK TRACKER v4.0', W - 92, H - 36);
ctx.restore();
}
// ================= 标题 =================
function drawTitle(t){
const a = clamp01(t / 1.3);
ctx.save();
ctx.globalAlpha = a;
ctx.textAlign = 'center';
const bandW = 800 * easeOutCubic(a);
const bandGrad = ctx.createLinearGradient(W/2 - bandW, 0, W/2 + bandW, 0);
bandGrad.addColorStop(0, 'rgba(56,189,248,0)');
bandGrad.addColorStop(0.5, 'rgba(56,189,248,0.10)');
bandGrad.addColorStop(1, 'rgba(56,189,248,0)');
ctx.fillStyle = bandGrad;
ctx.fillRect(W/2 - bandW, 42, bandW * 2, 116);
const grad = ctx.createLinearGradient(W/2 - 420, 0, W/2 + 420, 0);
grad.addColorStop(0, '#7dd3fc');
grad.addColorStop(0.45, '#e0f2fe');
grad.addColorStop(0.55, '#fef3c7');
grad.addColorStop(1, '#fbbf24');
ctx.shadowColor = 'rgba(56,189,248,0.7)';
ctx.shadowBlur = 30;
ctx.fillStyle = grad;
ctx.font = 'bold 64px "Microsoft YaHei", "PingFang SC", sans-serif';
ctx.fillText('从第28名到第1名', W / 2, 112);
ctx.shadowBlur = 0;
ctx.fillStyle = '#94a3b8';
ctx.font = '32px "Microsoft YaHei", "PingFang SC", sans-serif';
ctx.fillText('一条用四年画出的上升曲线', W / 2, 168);
const lineW = 560 * easeOutCubic(a);
const lg = ctx.createLinearGradient(W/2 - lineW, 0, W/2 + lineW, 0);
lg.addColorStop(0, 'rgba(56,189,248,0)');
lg.addColorStop(0.5, 'rgba(56,189,248,0.75)');
lg.addColorStop(1, 'rgba(56,189,248,0)');
ctx.fillStyle = lg;
ctx.fillRect(W/2 - lineW, 186, lineW * 2, 2);
ctx.restore();
}
// ================= 网格 + 横轴 =================
function drawGrid(t){
ctx.save();
ctx.strokeStyle = 'rgba(148,163,184,0.10)';
ctx.lineWidth = 1.2;
ctx.fillStyle = 'rgba(148,163,184,0.58)';
ctx.font = '22px "Consolas", "Microsoft YaHei", monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
[1, 10, 20, 30, 40, 50].forEach(r => {
const y = rankToY(r);
if (r === 1) {
const g = ctx.createLinearGradient(LEFT, 0, RIGHT, 0);
g.addColorStop(0, 'rgba(251,191,36,0)');
g.addColorStop(0.12, 'rgba(251,191,36,0.80)');
g.addColorStop(0.5, 'rgba(254,243,199,1)');
g.addColorStop(0.88, 'rgba(251,191,36,0.80)');
g.addColorStop(1, 'rgba(251,191,36,0)');
ctx.save();
ctx.strokeStyle = 'rgba(251,191,36,0.30)';
ctx.lineWidth = 10;
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 26;
ctx.beginPath();
ctx.moveTo(LEFT, y);
ctx.lineTo(RIGHT, y);
ctx.stroke();
ctx.restore();
ctx.save();
ctx.strokeStyle = g;
ctx.lineWidth = 3;
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 16;
ctx.beginPath();
ctx.moveTo(LEFT, y);
ctx.lineTo(RIGHT, y);
ctx.stroke();
ctx.restore();
ctx.strokeStyle = 'rgba(251,191,36,0.9)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(LEFT - 14, y);
ctx.lineTo(LEFT, y);
ctx.stroke();
ctx.save();
ctx.fillStyle = '#fbbf24';
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 14;
ctx.font = 'bold 23px "Consolas", "Microsoft YaHei", monospace';
ctx.fillText('第1名', LEFT - 24, y);
ctx.restore();
ctx.save();
ctx.fillStyle = '#fbbf24';
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.moveTo(LEFT - 22, y - 7);
ctx.lineTo(LEFT - 14, y);
ctx.lineTo(LEFT - 22, y + 7);
ctx.closePath();
ctx.fill();
ctx.restore();
return;
}
ctx.strokeStyle = 'rgba(148,163,184,0.10)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(LEFT, y);
ctx.lineTo(RIGHT, y);
ctx.stroke();
ctx.strokeStyle = 'rgba(56,189,248,0.35)';
ctx.beginPath();
ctx.moveTo(LEFT - 10, y);
ctx.lineTo(LEFT, y);
ctx.stroke();
ctx.fillStyle = 'rgba(148,163,184,0.58)';
ctx.fillText('第' + r + '名', LEFT - 24, y);
});
for (let i = 0; i < data.length; i++) {
const x = indexToX(i);
ctx.strokeStyle = 'rgba(56,189,248,0.045)';
ctx.beginPath();
ctx.moveTo(x, TOP);
ctx.lineTo(x, BOTTOM);
ctx.stroke();
}
const scanY = TOP + ((Math.sin(t * 0.7) * 0.5 + 0.5) * CHART_H);
const scanGrad = ctx.createLinearGradient(LEFT, scanY - 24, LEFT, scanY + 24);
scanGrad.addColorStop(0, 'rgba(56,189,248,0)');
scanGrad.addColorStop(0.5, 'rgba(56,189,248,0.12)');
scanGrad.addColorStop(1, 'rgba(56,189,248,0)');
ctx.fillStyle = scanGrad;
ctx.fillRect(LEFT, scanY - 24, CHART_W, 48);
ctx.strokeStyle = 'rgba(56,189,248,0.32)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(LEFT, TOP);
ctx.lineTo(LEFT, BOTTOM);
ctx.stroke();
ctx.strokeStyle = 'rgba(56,189,248,0.38)';
ctx.beginPath();
ctx.moveTo(LEFT, BOTTOM);
ctx.lineTo(RIGHT, BOTTOM);
ctx.stroke();
for (let i = 0; i < data.length; i++) {
const x = indexToX(i);
ctx.strokeStyle = 'rgba(56,189,248,0.30)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x, BOTTOM);
ctx.lineTo(x, BOTTOM + 7);
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
timeMarks.forEach(m => {
const x = indexToX(m.i);
ctx.strokeStyle = 'rgba(56,189,248,0.75)';
ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(56,189,248,0.6)';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.moveTo(x, BOTTOM);
ctx.lineTo(x, BOTTOM + 16);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(165,215,255,0.92)';
ctx.font = 'bold 21px "Consolas", monospace';
ctx.shadowColor = 'rgba(56,189,248,0.5)';
ctx.shadowBlur = 10;
ctx.fillText(m.text, x, BOTTOM + 46);
ctx.shadowBlur = 0;
});
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '21px "Consolas", "Microsoft YaHei", monospace';
[1, 2, 3].forEach(r => {
const y = classRankToY(r);
ctx.strokeStyle = 'rgba(251,191,36,0.22)';
ctx.beginPath();
ctx.moveTo(RIGHT, y);
ctx.lineTo(RIGHT + 14, y);
ctx.stroke();
ctx.fillStyle = 'rgba(251,191,36,0.85)';
ctx.fillText('班级第' + r, RIGHT + 22, y);
});
ctx.restore();
}
function drawTopLine(t){
const y = rankToY(1);
const appear = clamp01((t - 1.2) / 1.0);
if (appear <= 0) return;
ctx.save();
ctx.globalAlpha = appear;
const flow = (t * 0.45) % 1;
const flowX = LEFT + flow * CHART_W;
const flowGrad = ctx.createLinearGradient(flowX - 180, 0, flowX + 180, 0);
flowGrad.addColorStop(0, 'rgba(251,191,36,0)');
flowGrad.addColorStop(0.5, 'rgba(255,251,235,0.75)');
flowGrad.addColorStop(1, 'rgba(251,191,36,0)');
ctx.fillStyle = flowGrad;
ctx.fillRect(flowX - 180, y - 2, 360, 4);
for (let k = 0; k < 4; k++) {
const p = ((t * 0.30 + k / 4) % 1);
const px = LEFT + p * CHART_W;
ctx.beginPath();
ctx.arc(px, y, 3.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,251,235,0.95)';
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 16;
ctx.fill();
}
ctx.restore();
}
function getDrawnPoints(t){
const pos = getPos(t);
if (pos <= 0) return [];
const fullIndex = Math.floor(pos);
const frac = pos - fullIndex;
const pts = [];
for (let i = 0; i <= fullIndex; i++) {
pts.push({ x: indexToX(i), y: rankToY(data[i].rank) });
}
if (fullIndex < data.length - 1 && frac > 0) {
const x1 = indexToX(fullIndex), y1 = rankToY(data[fullIndex].rank);
const x2 = indexToX(fullIndex + 1), y2 = rankToY(data[fullIndex + 1].rank);
pts.push({ x: x1 + (x2 - x1) * frac, y: y1 + (y2 - y1) * frac });
}
return pts;
}
function pointsLength(pts){
let len = 0;
for (let i = 1; i < pts.length; i++) {
len += Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y);
}
return len;
}
function pointAtLength(pts, dist){
if (pts.length < 2) return pts[0] || {x:0,y:0};
let acc = 0;
for (let i = 1; i < pts.length; i++) {
const seg = Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y);
if (acc + seg >= dist) {
const r = (dist - acc) / seg;
return {
x: pts[i-1].x + (pts[i].x - pts[i-1].x) * r,
y: pts[i-1].y + (pts[i].y - pts[i-1].y) * r
};
}
acc += seg;
}
return pts[pts.length - 1];
}
// ================= 班级曲线(大幅增强) =================
function drawClassCurve(t){
const pos = getPos(t);
if (pos <= 0) return;
const fullIndex = Math.floor(pos);
const frac = pos - fullIndex;
const pts = [];
for (let i = 0; i <= fullIndex; i++) {
pts.push({ x: indexToX(i), y: classRankToY(data[i].classRank) });
}
if (fullIndex < data.length - 1 && frac > 0) {
const x1 = indexToX(fullIndex), y1 = classRankToY(data[fullIndex].classRank);
const x2 = indexToX(fullIndex + 1), y2 = classRankToY(data[fullIndex + 1].classRank);
pts.push({ x: x1 + (x2 - x1) * frac, y: y1 + (y2 - y1) * frac });
}
// 阴影层
ctx.save();
ctx.setLineDash([24, 14]);
ctx.strokeStyle = 'rgba(251,191,36,0.3)';
ctx.lineWidth = 14;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 30;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
// 主实心虚线
ctx.save();
ctx.setLineDash([24, 14]);
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 7;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.shadowColor = '#fcd34d';
ctx.shadowBlur = 20;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
// 数据节点放大
ctx.save();
for (let i = 0; i <= fullIndex; i++) {
const x = indexToX(i), y = classRankToY(data[i].classRank);
ctx.beginPath();
ctx.arc(x, y, 7.5, 0, Math.PI * 2);
ctx.fillStyle = '#fbbf24';
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.5;
ctx.stroke();
}
ctx.restore();
const totalLen = pointsLength(pts);
if (totalLen > 10) {
const count = 6;
for (let k = 0; k < count; k++) {
const ratio = ((t * 0.16 + k / count) % 1);
const pt = pointAtLength(pts, ratio * totalLen);
ctx.save();
ctx.beginPath();
ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,1)';
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 20;
ctx.fill();
ctx.restore();
}
}
}
// ================= 菱形数据点 =================
function drawDiamond(x, y, size, fill, stroke){
ctx.beginPath();
ctx.moveTo(x, y - size);
ctx.lineTo(x + size, y);
ctx.lineTo(x, y + size);
ctx.lineTo(x - size, y);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = stroke;
ctx.lineWidth = 2.5;
ctx.stroke();
}
function drawElectricPulse(pts, t){
if (pts.length < 2) return;
const totalLen = pointsLength(pts);
const pulseLen = 90;
const pulsePos = ((t * 0.55) % 1) * (totalLen + pulseLen);
const startD = Math.max(0, pulsePos - pulseLen);
const endD = Math.min(totalLen, pulsePos);
if (endD <= startD) return;
ctx.save();
ctx.strokeStyle = 'rgba(240,249,255,0.95)';
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.shadowColor = '#7dd3fc';
ctx.shadowBlur = 32;
ctx.beginPath();
const steps = 24;
for (let s = 0; s <= steps; s++) {
const d = startD + (endD - startD) * (s / steps);
const pt = pointAtLength(pts, d);
if (s === 0) ctx.moveTo(pt.x, pt.y);
else ctx.lineTo(pt.x, pt.y);
}
ctx.stroke();
ctx.restore();
}
const trailParticles = [];
function spawnTrail(x, y){
for (let i = 0; i < 2; i++) {
trailParticles.push({
x: x + (Math.random() - 0.5) * 8,
y: y + (Math.random() - 0.5) * 8,
vx: (Math.random() - 0.5) * 40,
vy: (Math.random() - 0.5) * 40 - 10,
life: 0.9 + Math.random() * 0.5,
age: 0,
size: 2 + Math.random() * 3,
hue: Math.random() < 0.3 ? 45 : 200
});
}
}
function updateTrail(dt){
for (let i = trailParticles.length - 1; i >= 0; i--) {
const p = trailParticles[i];
p.age += dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += 30 * dt;
if (p.age > p.life) trailParticles.splice(i, 1);
}
}
function drawTrail(){
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const p of trailParticles) {
const a = 1 - p.age / p.life;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * a, 0, Math.PI * 2);
ctx.fillStyle = p.hue === 45
? `rgba(253,224,140,${a * 0.8})`
: `rgba(147,197,253,${a * 0.85})`;
ctx.fill();
}
ctx.restore();
}
let lastHead = null;
// ================= 年级曲线(大幅增强) =================
function drawCurve(t, dt){
const pts = getDrawnPoints(t);
if (pts.length < 2) return;
ctx.save();
const areaGrad = ctx.createLinearGradient(0, TOP, 0, BOTTOM);
areaGrad.addColorStop(0, 'rgba(56,189,248,0.22)');
areaGrad.addColorStop(0.6, 'rgba(56,189,248,0.06)');
areaGrad.addColorStop(1, 'rgba(56,189,248,0)');
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[pts.length - 1].x, BOTTOM);
ctx.lineTo(pts[0].x, BOTTOM);
ctx.closePath();
ctx.fillStyle = areaGrad;
ctx.fill();
ctx.restore();
// 超粗底板
ctx.save();
ctx.strokeStyle = 'rgba(56,189,248,0.20)';
ctx.lineWidth = 24;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.shadowColor = 'rgba(56,189,248,0.8)';
ctx.shadowBlur = 50;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
// 主色层
ctx.save();
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 12;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.shadowColor = 'rgba(56,189,248,0.9)';
ctx.shadowBlur = 30;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
// 纯白高光层
ctx.save();
ctx.strokeStyle = 'rgba(240,249,255,0.95)';
ctx.lineWidth = 4;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
if (t > CURVE_START + 1 && t < CURVE_END + 2) {
drawElectricPulse(pts, t);
}
const head = pts[pts.length - 1];
const scanGrad = ctx.createLinearGradient(head.x - 26, 0, head.x + 26, 0);
scanGrad.addColorStop(0, 'rgba(56,189,248,0)');
scanGrad.addColorStop(0.5, 'rgba(56,189,248,0.24)');
scanGrad.addColorStop(1, 'rgba(56,189,248,0)');
ctx.save();
ctx.fillStyle = scanGrad;
ctx.fillRect(head.x - 26, TOP, 52, BOTTOM - TOP);
ctx.restore();
const totalLen = pointsLength(pts);
if (totalLen > 10) {
const count = 8;
for (let k = 0; k < count; k++) {
const ratio = ((t * 0.22 + k / count) % 1);
const pt = pointAtLength(pts, ratio * totalLen);
ctx.save();
ctx.beginPath();
ctx.arc(pt.x, pt.y, 5.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(240,249,255,1)';
ctx.shadowColor = '#38bdf8';
ctx.shadowBlur = 22;
ctx.fill();
ctx.restore();
}
}
const fullIndex = Math.floor(getPos(t));
for (let i = 0; i <= fullIndex; i++) {
const x = indexToX(i), y = rankToY(data[i].rank);
drawDiamond(x, y, 9, '#0ea5e9', '#ffffff');
}
ctx.save();
ctx.beginPath();
ctx.arc(head.x, head.y, 30, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(56,189,248,0.18)';
ctx.fill();
ctx.beginPath();
ctx.arc(head.x, head.y, 10, 0, Math.PI * 2);
ctx.fillStyle = '#f0f9ff';
ctx.shadowColor = '#38bdf8';
ctx.shadowBlur = 36;
ctx.fill();
ctx.restore();
if (t > CURVE_START && t < CURVE_END + 1.5) {
ctx.save();
ctx.strokeStyle = 'rgba(240,249,255,0.85)';
ctx.lineWidth = 1.5;
const r = 20;
for (let k = 0; k < 4; k++) {
const ang = t * 2.5 + k * Math.PI / 2;
ctx.beginPath();
ctx.arc(head.x, head.y, r, ang, ang + Math.PI / 3);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(head.x - r - 9, head.y);
ctx.lineTo(head.x - r - 2, head.y);
ctx.moveTo(head.x + r + 2, head.y);
ctx.lineTo(head.x + r + 9, head.y);
ctx.moveTo(head.x, head.y - r - 9);
ctx.lineTo(head.x, head.y - r - 2);
ctx.moveTo(head.x, head.y + r + 2);
ctx.lineTo(head.x, head.y + r + 9);
ctx.stroke();
ctx.restore();
}
if (t > CURVE_START && t < CURVE_END) {
if (lastHead) {
const dist = Math.hypot(head.x - lastHead.x, head.y - lastHead.y);
if (dist > 2) spawnTrail(head.x, head.y);
}
lastHead = { x: head.x, y: head.y };
}
updateTrail(dt);
drawTrail();
drawKeyLabels(t);
}
function drawKeyLabels(t){
const keys = [
{ i: 1, text: '跌至46名', color: '#f87171' },
{ i: 21, text: '一模16名', color: '#fbbf24' },
{ i: 22, text: '二模21名', color: '#fbbf24' },
{ i: 23, text: '三模13名', color: '#fbbf24' },
{ i: 24, text: '中考第1名', color: '#fbbf24' }
];
keys.forEach(k => {
const showTime = CURVE_START + (k.i / (data.length - 1)) * (CURVE_END - CURVE_START);
if (t < showTime + 0.15) return;
const x = indexToX(k.i);
const y = rankToY(data[k.i].rank);
const alpha = clamp01((t - showTime - 0.15) / 0.55);
const sinceShow = t - showTime;
if (sinceShow < 1.4) {
const p = sinceShow / 1.4;
ctx.save();
ctx.globalAlpha = (1 - p) * 0.7;
ctx.beginPath();
ctx.arc(x, y, 12 + p * 90, 0, Math.PI * 2);
ctx.strokeStyle = k.color;
ctx.lineWidth = 4 * (1 - p) + 1;
ctx.shadowColor = k.color;
ctx.shadowBlur = 26;
ctx.stroke();
ctx.restore();
const burstCount = 14;
for (let b = 0; b < burstCount; b++) {
const ang = (b / burstCount) * Math.PI * 2 + p * 1.5;
const dist = 20 + p * 95;
const px = x + Math.cos(ang) * dist;
const py = y + Math.sin(ang) * dist;
ctx.save();
ctx.globalAlpha = (1 - p) * 0.9;
ctx.beginPath();
ctx.arc(px, py, 3 * (1 - p) + 0.6, 0, Math.PI * 2);
ctx.fillStyle = k.color;
ctx.shadowColor = k.color;
ctx.shadowBlur = 14;
ctx.fill();
ctx.restore();
}
}
const pulse = ((t - showTime) % 1.4) / 1.4;
ctx.save();
ctx.globalAlpha = (1 - pulse) * 0.45;
ctx.beginPath();
ctx.arc(x, y, 16 + pulse * 44, 0, Math.PI * 2);
ctx.strokeStyle = k.color;
ctx.lineWidth = 2.5;
ctx.shadowColor = k.color;
ctx.shadowBlur = 18;
ctx.stroke();
ctx.restore();
ctx.save();
ctx.globalAlpha = alpha;
const scale = 0.8 + 0.2 * easeOutBack(alpha);
ctx.translate(x, y - 64);
ctx.scale(scale, scale);
ctx.translate(-x, -(y - 64));
ctx.font = 'bold 25px "Microsoft YaHei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
const tw = ctx.measureText(k.text).width;
const pad = 14;
const boxW = tw + pad * 2;
const boxH = 40;
const boxX = x - boxW / 2;
const boxY = y - 64;
ctx.fillStyle = 'rgba(5,10,20,0.9)';
roundRect(boxX, boxY, boxW, boxH, 10);
ctx.fill();
ctx.strokeStyle = k.color;
ctx.lineWidth = 2;
ctx.shadowColor = k.color;
ctx.shadowBlur = 16;
ctx.stroke();
ctx.fillStyle = k.color;
ctx.fillText(k.text, x, boxY + 29);
ctx.restore();
});
}
function drawCurrentInfo(t){
if (t < CURVE_START || t > CURVE_END) return;
const pos = getPos(t);
const idx = Math.min(data.length - 1, Math.max(0, Math.round(pos)));
const item = data[idx];
const alpha = clamp01((t - CURVE_START) / 0.6);
ctx.save();
ctx.globalAlpha = alpha;
ctx.textAlign = 'left';
ctx.fillStyle = '#94a3b8';
ctx.font = '29px "Microsoft YaHei", sans-serif';
ctx.fillText('当前考试:' + item.label + ' · ' + item.date, 170, 205);
ctx.save();
ctx.shadowColor = 'rgba(56,189,248,0.7)';
ctx.shadowBlur = 26;
ctx.fillStyle = '#f8fafc';
ctx.font = 'bold 74px "Microsoft YaHei", sans-serif';
ctx.fillText('第 ' + item.rank + ' 名', 170, 292);
ctx.restore();
ctx.save();
ctx.shadowColor = 'rgba(251,191,36,0.7)';
ctx.shadowBlur = 18;
ctx.fillStyle = 'rgba(251,191,36,0.98)';
ctx.font = 'bold 27px "Microsoft YaHei", sans-serif';
ctx.fillText('班级第 ' + item.classRank + ' 名', 170, 344);
ctx.restore();
ctx.restore();
}
// ================= 图例(加粗增强) =================
function drawLegend(t){
if (t < 1.0) return;
const a = clamp01((t - 1.0) / 0.8);
ctx.save();
ctx.globalAlpha = a;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = 'bold 23px "Microsoft YaHei", sans-serif';
const x = RIGHT - 470, y = 205;
// 蓝色实线
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 8;
ctx.shadowColor = '#38bdf8';
ctx.shadowBlur = 15;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + 46, y);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = '#cbd5e1';
ctx.fillText('年级名次', x + 58, y);
// 黄色虚线
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 6;
ctx.setLineDash([10, 6]);
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 15;
ctx.beginPath();
ctx.moveTo(x + 210, y);
ctx.lineTo(x + 256, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.shadowBlur = 0;
ctx.fillStyle = '#cbd5e1';
ctx.fillText('班级名次', x + 268, y);
ctx.restore();
}
// ================= 中考卡片 =================
function drawFinal(t){
if (t < CARD_START) return;
const a = clamp01((t - CARD_START) / 1.0);
ctx.save();
ctx.globalAlpha = a * 0.45;
const rot = t * 0.6;
for (let ring = 0; ring < 3; ring++) {
const r = 210 + ring * 36;
const segments = 24;
ctx.strokeStyle = ring === 1 ? 'rgba(251,191,36,0.7)' : 'rgba(56,189,248,0.35)';
ctx.lineWidth = ring === 1 ? 3 : 2;
ctx.shadowColor = ring === 1 ? '#fbbf24' : '#38bdf8';
ctx.shadowBlur = 18;
for (let i = 0; i < segments; i++) {
const ang1 = rot + (i / segments) * Math.PI * 2;
const ang2 = ang1 + (Math.PI * 2 / segments) * 0.6;
ctx.beginPath();
ctx.arc(W/2, 540, r, ang1, ang2);
ctx.stroke();
}
}
ctx.restore();
ctx.save();
for (let k = 0; k < 4; k++) {
const p = ((t - CARD_START + k * 0.45) % 1.8) / 1.8;
ctx.globalAlpha = (1 - p) * 0.3 * a;
ctx.beginPath();
ctx.arc(W/2, 540, 60 + p * 300, 0, Math.PI * 2);
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 3;
ctx.shadowColor = '#fbbf24';
ctx.shadowBlur = 26;
ctx.stroke();
}
ctx.restore();
const cardW = 720, cardH = 240;
const cardX = W / 2 - cardW / 2, cardY = 400;
ctx.save();
ctx.globalAlpha = a;
const scale = 0.82 + 0.18 * easeOutBack(a);
ctx.translate(W/2, cardY + cardH/2);
ctx.scale(scale, scale);
ctx.translate(-W/2, -(cardY + cardH/2));
ctx.save();
ctx.shadowColor = 'rgba(251,191,36,0.6)';
ctx.shadowBlur = 60;
ctx.fillStyle = 'rgba(8,14,26,0.95)';
roundRect(cardX, cardY, cardW, cardH, 26);
ctx.fill();
ctx.restore();
const borderGrad = ctx.createLinearGradient(cardX, cardY, cardX + cardW, cardY + cardH);
borderGrad.addColorStop(0, 'rgba(56,189,248,0.7)');
borderGrad.addColorStop(0.5, 'rgba(251,191,36,0.95)');
borderGrad.addColorStop(1, 'rgba(56,189,248,0.7)');
ctx.strokeStyle = borderGrad;
ctx.lineWidth = 3;
ctx.shadowColor = 'rgba(251,191,36,0.7)';
ctx.shadowBlur = 30;
roundRect(cardX, cardY, cardW, cardH, 26);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(251,191,36,0.5)';
ctx.lineWidth = 1.5;
for (let i = 0; i < 12; i++) {
const tx = cardX + 30 + i * (cardW - 60) / 11;
ctx.beginPath();
ctx.moveTo(tx, cardY + 12);
ctx.lineTo(tx, cardY + 20);
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.fillStyle = '#fbbf24';
ctx.font = 'bold 34px "Microsoft YaHei", sans-serif';
ctx.fillText('中 考 总 分', W / 2, cardY + 56);
// 修正总分 841
const numA = clamp01((t - CARD_START - 0.2) / 1.4);
const displayNum = (841 * easeOutCubic(numA)).toFixed(0);
ctx.save();
ctx.shadowColor = 'rgba(251,191,36,0.85)';
ctx.shadowBlur = 36;
ctx.fillStyle = '#f8fafc';
ctx.font = 'bold 80px "Consolas", "Microsoft YaHei", monospace';
ctx.fillText(numA >= 1 ? '841' : displayNum, W / 2, cardY + 146);
ctx.restore();
ctx.fillStyle = '#cbd5e1';
ctx.font = 'bold 28px "Microsoft YaHei", sans-serif';
ctx.fillText('全市第2 年级第1 班级第1', W / 2, cardY + 206);
ctx.restore();
if (t > FINAL_START) {
const a2 = clamp01((t - FINAL_START) / 0.9);
ctx.save();
ctx.globalAlpha = a2;
ctx.textAlign = 'center';
ctx.fillStyle = '#cbd5e1';
ctx.font = '28px "Microsoft YaHei", sans-serif';
// 修正语文分数 114
ctx.fillText('单科成绩:数学 120 · 英语 119 · 语文 114 · 物理 79 · 化学 69', W / 2, 690);
ctx.fillText('政治 70 · 历史 70 · 地理 50 · 生物 50', W / 2, 734);
ctx.restore();
ctx.save();
ctx.globalAlpha = a2;
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(56,189,248,0.65)';
ctx.shadowBlur = 24;
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 42px "Microsoft YaHei", sans-serif';
ctx.fillText('不是一次爆发,是四年坚持', W / 2, 800);
ctx.restore();
}
}
function applyCamera(t){
const breath = 1 + 0.006 * Math.sin(t * 0.8);
ctx.translate(W/2, H/2);
ctx.scale(breath, breath);
ctx.translate(-W/2, -H/2);
}
let prevT = 0;
function draw(t){
const dt = Math.min(0.05, t - prevT);
prevT = t;
ctx.clearRect(0, 0, W, H);
drawBackground(t);
drawHUD(t);
ctx.save();
applyCamera(t);
drawTitle(t);
drawGrid(t);
drawClassCurve(t); // 先画班级曲线
drawCurve(t, dt); // 再画年级曲线
drawTopLine(t);
drawCurrentInfo(t);
drawLegend(t);
drawFinal(t);
ctx.restore();
}
const startTime = performance.now();
function loop(now){
const t = (now - startTime) / 1000;
draw(Math.min(t, TOTAL));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>